From a9cb866f62fe9c2f7c8f9a8c3ef1a23cae556781 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 01:06:03 +0000 Subject: [PATCH 01/29] chore(ui): refresh de control ui locale --- ui/src/i18n/.i18n/de.meta.json | 9 +++------ ui/src/i18n/.i18n/de.tm.jsonl | 2 ++ ui/src/i18n/locales/de.ts | 4 ++-- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/ui/src/i18n/.i18n/de.meta.json b/ui/src/i18n/.i18n/de.meta.json index f9c772d1d061..2b2fb3578712 100644 --- a/ui/src/i18n/.i18n/de.meta.json +++ b/ui/src/i18n/.i18n/de.meta.json @@ -1,14 +1,11 @@ { - "fallbackKeys": [ - "chat.commentaryLabel", - "chat.commentaryToggle" - ], - "generatedAt": "2026-06-30T23:31:03.027Z", + "fallbackKeys": [], + "generatedAt": "2026-07-01T01:06:02.814Z", "locale": "de", "model": "claude-opus-4-8", "provider": "anthropic", "sourceHash": "2efa88c26ff88470a76b18ae218dd9c325165f98587f359ed51dbfb51a7d8428", "totalKeys": 1418, - "translatedKeys": 1416, + "translatedKeys": 1418, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/de.tm.jsonl b/ui/src/i18n/.i18n/de.tm.jsonl index 1062727c679b..58a2457ae107 100644 --- a/ui/src/i18n/.i18n/de.tm.jsonl +++ b/ui/src/i18n/.i18n/de.tm.jsonl @@ -8,6 +8,7 @@ {"cache_key":"15794c3615bcfc57208f237c781c4012d7f3f6208b565699e32f05e1d50af7e2","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.detailWorkerLogs","source_path":"ui/src/i18n/locales/de.ts","src_lang":"en","text":"Worker logs","text_hash":"67766b9f222a7ccdae6beb7d6e7877d1a13abb8a346a8c5c803a4380bdf851b1","tgt_lang":"de","translated":"Worker-Protokolle","updated_at":"2026-06-16T14:12:53.111Z"} {"cache_key":"167ace856862c782b3dc9dad34349a19fe2e1a840b9e337d430a4603e4defdcf","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.label","source_path":"ui/src/i18n/locales/de.ts","src_lang":"en","text":"Session workspace","text_hash":"c0e8ea0cf983d14e8ba3f8fc28976954d637fd50f807c69e9695715fd2384b78","tgt_lang":"de","translated":"Sitzungsarbeitsbereich","updated_at":"2026-06-16T14:12:59.821Z"} {"cache_key":"181665fcb0f4b339a06f80616b06f736765b8a46fd4d2483d733f4dac57ec9e0","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.missing","source_path":"ui/src/i18n/locales/de.ts","src_lang":"en","text":"Missing","text_hash":"6be36ca49ee85210c5d1ad9c377d90a9859c66d889110a2a5b0dccd390d12e20","tgt_lang":"de","translated":"Fehlt","updated_at":"2026-06-16T14:13:06.672Z"} +{"cache_key":"1a1ff88c753da1e0b553d1ee4be8bb037a254c4a942b60ea839bd775774c26df","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.commentaryLabel","source_path":"ui/src/i18n/locales/de.ts","src_lang":"en","text":"Commentary","text_hash":"4a7a75ab79cde05b0b1baa8f7a704c991586ea44d7e7793e57178ef56f778ff8","tgt_lang":"de","translated":"Kommentar","updated_at":"2026-07-01T01:06:02.805Z"} {"cache_key":"1b2a8d28defa2a445b54fde09adb8e7647cd5fa790d833f050b5c21386730c71","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.artifactCount","source_path":"ui/src/i18n/locales/de.ts","src_lang":"en","text":"{count} artifacts","text_hash":"022b6b55a10f1864b7aff7c307e49ab37e11e6999111fb87349f040d100abba3","tgt_lang":"de","translated":"{count} Artefakte","updated_at":"2026-06-16T14:13:06.672Z"} {"cache_key":"1d4bb403c759a82cef8ee1177c6d355e076966e1a95b8e6661e10f43e826e827","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.empty","source_path":"ui/src/i18n/locales/de.ts","src_lang":"en","text":"No files touched in this session yet","text_hash":"6b295e4e11bcdd52340c4cc7987565f848cf477a1f0b96c0f47a2b718418298e","tgt_lang":"de","translated":"In dieser Sitzung wurden noch keine Dateien bearbeitet","updated_at":"2026-06-16T14:12:59.821Z"} {"cache_key":"1d9a4877504f1fa0218779ff26c43cf16734138e73e01df52a22eb2733c8f0d2","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.viewRunning","source_path":"ui/src/i18n/locales/de.ts","src_lang":"en","text":"Running","text_hash":"f4ccae29e1bb0c20a124570a1b43f4347ea94bba9f84ffdfddd9c7445b126128","tgt_lang":"de","translated":"Läuft","updated_at":"2026-06-17T14:13:07.391Z"} @@ -37,6 +38,7 @@ {"cache_key":"51a04cef539b3328516dfc9101a46f5cce2e2921f8b19549b4144c763758400f","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.autoRefresh30s","source_path":"ui/src/i18n/locales/de.ts","src_lang":"en","text":"30s","text_hash":"d3382a4f0e03f8b14cf99424376886c236f1503d4b332137667484fc96d58fc4","tgt_lang":"de","translated":"30s","updated_at":"2026-06-17T14:13:12.251Z"} {"cache_key":"563460816d3948dc9e063429950fd02b7f97ccd41ea95b4f82aef2639ca3c805","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.detailAutomationSkills","source_path":"ui/src/i18n/locales/de.ts","src_lang":"en","text":"Skills: {skills}","text_hash":"4788d5f9db66e1421a762bbd942c64450c73d2145a6ef929ce32a919a0f2e3a1","tgt_lang":"de","translated":"Skills: {skills}","updated_at":"2026-06-16T14:12:53.111Z"} {"cache_key":"57473c8a09374714eceaf4b6a43a37ce1d388c4b12e5d78f1d569698095651fb","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.truncated","source_path":"ui/src/i18n/locales/de.ts","src_lang":"en","text":"Showing the first matching files. Refine the search to narrow results.","text_hash":"62005877ff0fc1f73ce05ca4c459157c57a8c57a3443245b1df4d3b033df98e9","tgt_lang":"de","translated":"Es werden die ersten passenden Dateien angezeigt. Verfeinern Sie die Suche, um die Ergebnisse einzugrenzen.","updated_at":"2026-06-16T14:13:06.672Z"} +{"cache_key":"5786703813168994fba14e4dff988d5692e5e29a64cf341eb483932fe9d3e06e","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.commentaryToggle","source_path":"ui/src/i18n/locales/de.ts","src_lang":"en","text":"Keep commentary after the final answer","text_hash":"febdff0791d1ceeddb2b7596da8282f4dcfd5cff4cad11dea4c0a23944cd8ffe","tgt_lang":"de","translated":"Kommentar nach der endgültigen Antwort behalten","updated_at":"2026-07-01T01:06:02.805Z"} {"cache_key":"59a3e874dc9b1299daf4a2644ec2659a6e9c50587c91ac357f24215c16b94169","model":"claude-opus-4-8","provider":"anthropic","segment_id":"cron.jobDetail.cwd","source_path":"ui/src/i18n/locales/de.ts","src_lang":"en","text":"CWD","text_hash":"0217f1cb7725737f15a6710df3bcfa3bc10a239f0f7801ec3d7168e675f5ebd6","tgt_lang":"de","translated":"CWD","updated_at":"2026-06-16T14:13:08.672Z"} {"cache_key":"5b0ab59138fd65f7f3a236db3c04458d74c99539c8ab736086c9637395c06d55","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.expand","source_path":"ui/src/i18n/locales/de.ts","src_lang":"en","text":"Expand session workspace","text_hash":"ac1d210db40c5026879774849ad74a9e1247523192a795ac33965b3ee72691c2","tgt_lang":"de","translated":"Sitzungsarbeitsbereich aufklappen","updated_at":"2026-06-16T14:12:59.821Z"} {"cache_key":"5f60d04e50be0e22c14d761f808667c2dfd5623e67247c367c1d280613bcffca","model":"gpt-5.5","provider":"openai","segment_id":"workboard.badgeWorkerLogs","source_path":"ui/src/i18n/locales/de.ts","src_lang":"en","text":"{count} worker logs","text_hash":"2923e2a84e6ed0ca048d280206198b156da6148859e474498d335a44e323e0a8","tgt_lang":"de","translated":"{count} Worker-Protokolle","updated_at":"2026-05-30T15:38:11.508Z"} diff --git a/ui/src/i18n/locales/de.ts b/ui/src/i18n/locales/de.ts index f0d16cda19db..b2669bcc28a2 100644 --- a/ui/src/i18n/locales/de.ts +++ b/ui/src/i18n/locales/de.ts @@ -1340,8 +1340,8 @@ export const de: TranslationMap = { settings: "Chat-Einstellungen", thinkingToggle: "Ausgabe des Assistenten ein-/ausblenden", toolCallsToggle: "Tool-Aufrufe und Tool-Ergebnisse umschalten", - commentaryToggle: "Keep commentary after the final answer", - commentaryLabel: "Commentary", + commentaryToggle: "Kommentar nach der endgültigen Antwort behalten", + commentaryLabel: "Kommentar", autoScrollMode: "Automatischer Scrollmodus", autoScrollAlways: "Immer", autoScrollNearBottom: "Nahe am unteren Rand", From ce9166d1cd8404db51004bbdd5edc95d229788c8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 01:06:07 +0000 Subject: [PATCH 02/29] chore(ui): refresh zh-CN control ui locale --- ui/src/i18n/.i18n/zh-CN.meta.json | 9 +++------ ui/src/i18n/.i18n/zh-CN.tm.jsonl | 2 ++ ui/src/i18n/locales/zh-CN.ts | 4 ++-- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/ui/src/i18n/.i18n/zh-CN.meta.json b/ui/src/i18n/.i18n/zh-CN.meta.json index 57a0da85c3ca..eef0c59af02f 100644 --- a/ui/src/i18n/.i18n/zh-CN.meta.json +++ b/ui/src/i18n/.i18n/zh-CN.meta.json @@ -1,14 +1,11 @@ { - "fallbackKeys": [ - "chat.commentaryLabel", - "chat.commentaryToggle" - ], - "generatedAt": "2026-06-30T23:31:02.741Z", + "fallbackKeys": [], + "generatedAt": "2026-07-01T01:06:06.608Z", "locale": "zh-CN", "model": "claude-opus-4-8", "provider": "anthropic", "sourceHash": "2efa88c26ff88470a76b18ae218dd9c325165f98587f359ed51dbfb51a7d8428", "totalKeys": 1418, - "translatedKeys": 1416, + "translatedKeys": 1418, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/zh-CN.tm.jsonl b/ui/src/i18n/.i18n/zh-CN.tm.jsonl index 5493e91c5c99..870fc3ed4324 100644 --- a/ui/src/i18n/.i18n/zh-CN.tm.jsonl +++ b/ui/src/i18n/.i18n/zh-CN.tm.jsonl @@ -26,6 +26,7 @@ {"cache_key":"2a042e30e5ed0ab3249aec806adeb23faae45e54129d891c66be56c053691fac","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.viewRunning","source_path":"ui/src/i18n/locales/zh-CN.ts","src_lang":"en","text":"Running","text_hash":"f4ccae29e1bb0c20a124570a1b43f4347ea94bba9f84ffdfddd9c7445b126128","tgt_lang":"zh-CN","translated":"运行中","updated_at":"2026-06-17T14:13:12.872Z"} {"cache_key":"2e34d6b2c15bef88f3956b229e7f19e159bbec33ebbb1a4f1e12b09c2f220c9c","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.agentFilterUnassigned","source_path":"ui/src/i18n/locales/zh-CN.ts","src_lang":"en","text":"Unassigned (uses {agent})","text_hash":"2700af0c4ab5e86726f72a723ecdf50370b87690db35f00b83723d6457879c8e","tgt_lang":"zh-CN","translated":"未分配(使用 {agent})","updated_at":"2026-06-17T14:13:12.872Z"} {"cache_key":"31bd00f16cecd8300c9596052aa31783e4befbb742ecc30d75dd3d8f5513a6ab","model":"gpt-5.5","provider":"openai","segment_id":"workboard.eventAttachmentAdded","source_path":"ui/src/i18n/locales/zh-CN.ts","src_lang":"en","text":"Attachment added","text_hash":"f39a309fb0054d8e6c512733d6f3a4791c6b63157a388d72f635574d98b49b3e","tgt_lang":"zh-CN","translated":"已添加附件","updated_at":"2026-05-30T15:38:03.150Z"} +{"cache_key":"320080affff39b949ba3816ddf77eb4875a338382703f0d62212a7d30b63112b","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.commentaryToggle","source_path":"ui/src/i18n/locales/zh-CN.ts","src_lang":"en","text":"Keep commentary after the final answer","text_hash":"febdff0791d1ceeddb2b7596da8282f4dcfd5cff4cad11dea4c0a23944cd8ffe","tgt_lang":"zh-CN","translated":"在最终答案后保留评述","updated_at":"2026-07-01T01:06:06.598Z"} {"cache_key":"3299610a677df332f9cd54acd764153498ae8932b2e71f7a31b42a6ebe8470d1","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.detailAutomationWorkspace","source_path":"ui/src/i18n/locales/zh-CN.ts","src_lang":"en","text":"Workspace: {workspace}","text_hash":"17f5e696e557a646a9003fc8448f6f6761f5fe6bdf7478f750f471496e87c17b","tgt_lang":"zh-CN","translated":"工作区:{workspace}","updated_at":"2026-06-16T14:12:56.348Z"} {"cache_key":"33044caef31e28251d133d51dfcd57bd21c0846dbe42659f9b6ab8b5e5e74613","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.truncated","source_path":"ui/src/i18n/locales/zh-CN.ts","src_lang":"en","text":"Showing the first matching files. Refine the search to narrow results.","text_hash":"62005877ff0fc1f73ce05ca4c459157c57a8c57a3443245b1df4d3b033df98e9","tgt_lang":"zh-CN","translated":"正在显示前几个匹配的文件。优化搜索以缩小结果范围。","updated_at":"2026-06-16T14:13:07.632Z"} {"cache_key":"33a6f66bf057a04f7e4130cef76902c4d1cd71e6e052781f4ac5cecaadc89066","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.healthBlocked","source_path":"ui/src/i18n/locales/zh-CN.ts","src_lang":"en","text":"blocked","text_hash":"6973dddd3ef9cb6a2932702f31777faad9c9bf3124d147a84f31aadb6d139546","tgt_lang":"zh-CN","translated":"已阻塞","updated_at":"2026-06-17T14:13:17.789Z"} @@ -62,6 +63,7 @@ {"cache_key":"71a7e46d96256b5a077c7d7a9b787172c9705701f0b9d2adc30710fd3680fe65","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.dependencyStatusMissing","source_path":"ui/src/i18n/locales/zh-CN.ts","src_lang":"en","text":"Missing","text_hash":"6be36ca49ee85210c5d1ad9c377d90a9859c66d889110a2a5b0dccd390d12e20","tgt_lang":"zh-CN","translated":"缺失","updated_at":"2026-06-16T14:13:02.064Z"} {"cache_key":"79718c67a32a5715aaf82061c30249f8f266d590ab01ac64dc9d6c4e7768fbe3","model":"gpt-5.5","provider":"openai","segment_id":"chat.toolCards.toolError","source_path":"ui/src/i18n/locales/zh-CN.ts","src_lang":"en","text":"Tool error","text_hash":"a6c64c286a8795034ac5030b74633d3b476b5375e094485698b982879b0bb617","tgt_lang":"zh-CN","translated":"工具错误","updated_at":"2026-05-31T06:43:44.343Z"} {"cache_key":"79fde73afecc4d79dd4bd35fe6a18eac854011d3d87673f3cc357e1c282c37c5","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.autoRefresh30s","source_path":"ui/src/i18n/locales/zh-CN.ts","src_lang":"en","text":"30s","text_hash":"d3382a4f0e03f8b14cf99424376886c236f1503d4b332137667484fc96d58fc4","tgt_lang":"zh-CN","translated":"30秒","updated_at":"2026-06-17T14:13:17.789Z"} +{"cache_key":"7a8df63f8b703bbc628a80688de508d6b42d8382901f1d64f9d230f166c9fbd9","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.commentaryLabel","source_path":"ui/src/i18n/locales/zh-CN.ts","src_lang":"en","text":"Commentary","text_hash":"4a7a75ab79cde05b0b1baa8f7a704c991586ea44d7e7793e57178ef56f778ff8","tgt_lang":"zh-CN","translated":"评述","updated_at":"2026-07-01T01:06:06.598Z"} {"cache_key":"7bf21d49d9e5d33a53cd17520b7bdd327e84c67d252f1d27b37f7fc8565da821","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.detailProof","source_path":"ui/src/i18n/locales/zh-CN.ts","src_lang":"en","text":"Proof","text_hash":"7fbb3ccf9640651f69af3626de6836fb302a0a088c7cd27721c367b8b530e502","tgt_lang":"zh-CN","translated":"凭证","updated_at":"2026-06-16T14:12:56.348Z"} {"cache_key":"7cd65ecbd8ff8e1bd46b6b7415a7c9ca5af9e1e14f4ad2852d1ef922844c5e24","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.healthFailedAttempts","source_path":"ui/src/i18n/locales/zh-CN.ts","src_lang":"en","text":"failed attempts","text_hash":"fd9023af0795825a458100ddbe894a7a8f603324a2b7ad2305d4c9d2334cbd26","tgt_lang":"zh-CN","translated":"失败尝试","updated_at":"2026-06-17T14:13:17.789Z"} {"cache_key":"80fb46ada1bdfad885dc5352cab2a62a36de308027367cb11269c689b1c6448a","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.searchResults","source_path":"ui/src/i18n/locales/zh-CN.ts","src_lang":"en","text":"Search results","text_hash":"e978b00de465a271a13bf2f6b9d74d67fdfaa7d973a37378fa32f988c3280599","tgt_lang":"zh-CN","translated":"搜索结果","updated_at":"2026-06-16T14:13:07.632Z"} diff --git a/ui/src/i18n/locales/zh-CN.ts b/ui/src/i18n/locales/zh-CN.ts index bec484c01a14..7a01f1940670 100644 --- a/ui/src/i18n/locales/zh-CN.ts +++ b/ui/src/i18n/locales/zh-CN.ts @@ -1292,8 +1292,8 @@ export const zh_CN: TranslationMap = { settings: "聊天设置", thinkingToggle: "切换助手思考/工作输出", toolCallsToggle: "切换工具调用和工具结果", - commentaryToggle: "Keep commentary after the final answer", - commentaryLabel: "Commentary", + commentaryToggle: "在最终答案后保留评述", + commentaryLabel: "评述", autoScrollMode: "自动滚动模式", autoScrollAlways: "始终", autoScrollNearBottom: "接近底部", From 6b67ada87ad934f6dd8afbda95830381b3767247 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 01:06:19 +0000 Subject: [PATCH 03/29] chore(ui): refresh pt-BR control ui locale --- ui/src/i18n/.i18n/pt-BR.meta.json | 9 +++------ ui/src/i18n/.i18n/pt-BR.tm.jsonl | 2 ++ ui/src/i18n/locales/pt-BR.ts | 4 ++-- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/ui/src/i18n/.i18n/pt-BR.meta.json b/ui/src/i18n/.i18n/pt-BR.meta.json index 9e01fb2f2ac4..30e8d433cd95 100644 --- a/ui/src/i18n/.i18n/pt-BR.meta.json +++ b/ui/src/i18n/.i18n/pt-BR.meta.json @@ -1,14 +1,11 @@ { - "fallbackKeys": [ - "chat.commentaryLabel", - "chat.commentaryToggle" - ], - "generatedAt": "2026-06-30T23:31:02.922Z", + "fallbackKeys": [], + "generatedAt": "2026-07-01T01:06:19.020Z", "locale": "pt-BR", "model": "claude-opus-4-8", "provider": "anthropic", "sourceHash": "2efa88c26ff88470a76b18ae218dd9c325165f98587f359ed51dbfb51a7d8428", "totalKeys": 1418, - "translatedKeys": 1416, + "translatedKeys": 1418, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/pt-BR.tm.jsonl b/ui/src/i18n/.i18n/pt-BR.tm.jsonl index 067ca1d50c80..6aca8b475831 100644 --- a/ui/src/i18n/.i18n/pt-BR.tm.jsonl +++ b/ui/src/i18n/.i18n/pt-BR.tm.jsonl @@ -20,6 +20,7 @@ {"cache_key":"2582a2593221ed2625f3624da8e0ffbedaed08160e63d72a48f90c9b07367742","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.detailTitle","source_path":"ui/src/i18n/locales/pt-BR.ts","src_lang":"en","text":"Card details","text_hash":"93985f84673405070ffdf7e6f64175caff0f2c489c10e40627718525e79af631","tgt_lang":"pt-BR","translated":"Detalhes do cartão","updated_at":"2026-06-16T14:13:11.260Z"} {"cache_key":"2817fc682cb8dfe96ab382151d263b508ef0c8e0923b7ea75d89df7931002b8e","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.viewRunning","source_path":"ui/src/i18n/locales/pt-BR.ts","src_lang":"en","text":"Running","text_hash":"f4ccae29e1bb0c20a124570a1b43f4347ea94bba9f84ffdfddd9c7445b126128","tgt_lang":"pt-BR","translated":"Em execução","updated_at":"2026-06-17T14:13:15.046Z"} {"cache_key":"28668a02cb2681672396a70daa274c4364cce32882d43c2f58969eefaf947fee","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.agentFilterUnassigned","source_path":"ui/src/i18n/locales/pt-BR.ts","src_lang":"en","text":"Unassigned (uses {agent})","text_hash":"2700af0c4ab5e86726f72a723ecdf50370b87690db35f00b83723d6457879c8e","tgt_lang":"pt-BR","translated":"Não atribuído (usa {agent})","updated_at":"2026-06-17T14:13:15.046Z"} +{"cache_key":"28fd08f745525853e7e3e4c1237975f8c07d07cf4256c45d176d31c2cd1f0f88","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.commentaryToggle","source_path":"ui/src/i18n/locales/pt-BR.ts","src_lang":"en","text":"Keep commentary after the final answer","text_hash":"febdff0791d1ceeddb2b7596da8282f4dcfd5cff4cad11dea4c0a23944cd8ffe","tgt_lang":"pt-BR","translated":"Manter comentários após a resposta final","updated_at":"2026-07-01T01:06:19.014Z"} {"cache_key":"29a3bfdf9cb14ec24751f2be576b51d0cd47954b5ba67562df34b35cf115b3c5","model":"claude-opus-4-8","provider":"anthropic","segment_id":"skillWorkshop.header.useCurrentChat","source_path":"ui/src/i18n/locales/pt-BR.ts","src_lang":"en","text":"Use current chat","text_hash":"fbc1ffd63daa506e927c7a85f6e43acd11e0b8c9f52a3951fc782b236ce9a787","tgt_lang":"pt-BR","translated":"Usar conversa atual","updated_at":"2026-06-16T14:13:11.259Z"} {"cache_key":"2a6b55bd02cddd364a7b1464ec13a1b141da6d9889b97ec3bfc7a252346fe09b","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.artifactCount","source_path":"ui/src/i18n/locales/pt-BR.ts","src_lang":"en","text":"{count} artifacts","text_hash":"022b6b55a10f1864b7aff7c307e49ab37e11e6999111fb87349f040d100abba3","tgt_lang":"pt-BR","translated":"{count} artefatos","updated_at":"2026-06-16T14:13:25.058Z"} {"cache_key":"2b5f6b5471c21d5d4a6fb037d707b3db112d3903b48e24cfe2e79782e7ac2233","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.detailProof","source_path":"ui/src/i18n/locales/pt-BR.ts","src_lang":"en","text":"Proof","text_hash":"7fbb3ccf9640651f69af3626de6836fb302a0a088c7cd27721c367b8b530e502","tgt_lang":"pt-BR","translated":"Comprovação","updated_at":"2026-06-16T14:13:11.260Z"} @@ -69,6 +70,7 @@ {"cache_key":"8a5394bf53b38de5b08d8c7fb25c98d145b9f8808056b2b3e181824fb52f5888","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.unknownStatus","source_path":"ui/src/i18n/locales/pt-BR.ts","src_lang":"en","text":"Unknown","text_hash":"b764cdc0eab7137467211272fa539f1260d1bf2e71bcf6ff3bdc960f5c16aa14","tgt_lang":"pt-BR","translated":"Desconhecido","updated_at":"2026-06-16T14:13:17.394Z"} {"cache_key":"8dc397b6b255a6c5f4942112a5f5062eb723d8408b8b13405d49354d2d660afe","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.workspace","source_path":"ui/src/i18n/locales/pt-BR.ts","src_lang":"en","text":"Session","text_hash":"6959b4159575d8dd76d9f3bbe2c6437904f861e7860c35abd18deffb1c3425a0","tgt_lang":"pt-BR","translated":"Sessão","updated_at":"2026-06-16T14:13:17.394Z"} {"cache_key":"8ecc8913371195e3f3ad39da423cd813f88a57ed99f6377ceee62566171d1257","model":"gpt-5.5","provider":"openai","segment_id":"languages.ru","source_path":"ui/src/i18n/locales/pt-BR.ts","src_lang":"en","text":"Русский (Russian)","text_hash":"ea81bf0fd46410b501bddee074ab6f00b0cdf377a6cafe608dcf2c28f7cb2f4e","tgt_lang":"pt-BR","translated":"Русский (russo)","updated_at":"2026-06-26T21:43:22.390Z"} +{"cache_key":"8fe25f322b4d8ceb82728919e7f9931ca8245aab2e515b118bad138ab2e4337c","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.commentaryLabel","source_path":"ui/src/i18n/locales/pt-BR.ts","src_lang":"en","text":"Commentary","text_hash":"4a7a75ab79cde05b0b1baa8f7a704c991586ea44d7e7793e57178ef56f778ff8","tgt_lang":"pt-BR","translated":"Comentários","updated_at":"2026-07-01T01:06:19.014Z"} {"cache_key":"90ccb537cd248496ee547476b10bec0e580cdaf3cf1ddb4726e64f95e14cd072","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.healthReadyUnassigned","source_path":"ui/src/i18n/locales/pt-BR.ts","src_lang":"en","text":"ready unassigned","text_hash":"1e1a31a02e9da6ffa99a459bfd82e5af723794c2175405a5a0d6a32afa955167","tgt_lang":"pt-BR","translated":"pronto não atribuído","updated_at":"2026-06-17T14:13:19.801Z"} {"cache_key":"91a9cfbbe6b8d1cab7ee71cf5a3479fbc95226eee06490a8e1ccf1c52050256f","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.viewStale","source_path":"ui/src/i18n/locales/pt-BR.ts","src_lang":"en","text":"Stale","text_hash":"40c9e59c5e152b0ae9affc84d8461c29b75a7709e4506307eeedf246b526014e","tgt_lang":"pt-BR","translated":"Obsoleto","updated_at":"2026-06-17T14:13:15.046Z"} {"cache_key":"945fb4725e382dbce447cf68c998e902cd347429a820c41057cf46d5c3707fa6","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.detailWorkerLogs","source_path":"ui/src/i18n/locales/pt-BR.ts","src_lang":"en","text":"Worker logs","text_hash":"67766b9f222a7ccdae6beb7d6e7877d1a13abb8a346a8c5c803a4380bdf851b1","tgt_lang":"pt-BR","translated":"Logs do worker","updated_at":"2026-06-16T14:13:11.260Z"} diff --git a/ui/src/i18n/locales/pt-BR.ts b/ui/src/i18n/locales/pt-BR.ts index 9831fef043e5..a6c007362e5a 100644 --- a/ui/src/i18n/locales/pt-BR.ts +++ b/ui/src/i18n/locales/pt-BR.ts @@ -1331,8 +1331,8 @@ export const pt_BR: TranslationMap = { settings: "Configurações do chat", thinkingToggle: "Alternar saída de pensamento/trabalho do assistente", toolCallsToggle: "Alternar chamadas de ferramenta e resultados de ferramenta", - commentaryToggle: "Keep commentary after the final answer", - commentaryLabel: "Commentary", + commentaryToggle: "Manter comentários após a resposta final", + commentaryLabel: "Comentários", autoScrollMode: "Modo de rolagem automática", autoScrollAlways: "Sempre", autoScrollNearBottom: "Próximo ao final", From 006c2f7a2b28433753521f2f9f729ba0032b51ff Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 01:06:41 +0000 Subject: [PATCH 04/29] chore(ui): refresh es control ui locale --- ui/src/i18n/.i18n/es.meta.json | 9 +++------ ui/src/i18n/.i18n/es.tm.jsonl | 2 ++ ui/src/i18n/locales/es.ts | 4 ++-- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/ui/src/i18n/.i18n/es.meta.json b/ui/src/i18n/.i18n/es.meta.json index 25cab1190902..f422c121ebaa 100644 --- a/ui/src/i18n/.i18n/es.meta.json +++ b/ui/src/i18n/.i18n/es.meta.json @@ -1,14 +1,11 @@ { - "fallbackKeys": [ - "chat.commentaryLabel", - "chat.commentaryToggle" - ], - "generatedAt": "2026-06-30T23:31:03.131Z", + "fallbackKeys": [], + "generatedAt": "2026-07-01T01:06:39.921Z", "locale": "es", "model": "claude-opus-4-8", "provider": "anthropic", "sourceHash": "2efa88c26ff88470a76b18ae218dd9c325165f98587f359ed51dbfb51a7d8428", "totalKeys": 1418, - "translatedKeys": 1416, + "translatedKeys": 1418, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/es.tm.jsonl b/ui/src/i18n/.i18n/es.tm.jsonl index f1c6ea30e74f..50da8b7c32f9 100644 --- a/ui/src/i18n/.i18n/es.tm.jsonl +++ b/ui/src/i18n/.i18n/es.tm.jsonl @@ -20,6 +20,7 @@ {"cache_key":"24a6de859b0956a90013999bf3dba224d13e795496ea46fae64faed2a199e17a","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.refresh","source_path":"ui/src/i18n/locales/es.ts","src_lang":"en","text":"Refresh session workspace","text_hash":"c7a97b20a3a3ce348239c4893c99f1902d44877567cb32f752c30cdfbc9a2468","tgt_lang":"es","translated":"Actualizar espacio de trabajo de la sesión","updated_at":"2026-06-16T14:14:17.778Z"} {"cache_key":"27fa89c61adc150babc3ded34fdb5864d9ae99d19226958fe7ea11b563e69377","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.summary","source_path":"ui/src/i18n/locales/es.ts","src_lang":"en","text":"Session workspace summary","text_hash":"1ed422c34dc1802d4c7366164ae810c496e206fe82e8e6565cefc38230b56bb4","tgt_lang":"es","translated":"Resumen del espacio de trabajo de la sesión","updated_at":"2026-06-16T14:14:23.912Z"} {"cache_key":"281e153d782e5cddfb08ab96830e0df2c057cac4b6ed26c0240247b35b39e675","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.detailRun","source_path":"ui/src/i18n/locales/es.ts","src_lang":"en","text":"Run","text_hash":"00d60e31a4e6b8344d4201f25a6a7dee770713107f6d097abb01559d32b17f26","tgt_lang":"es","translated":"Ejecución","updated_at":"2026-06-16T14:14:11.214Z"} +{"cache_key":"2a12488d59c8ed57ec0d57bacf354486376a131687975f7866c863a04cfcd6b5","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.commentaryToggle","source_path":"ui/src/i18n/locales/es.ts","src_lang":"en","text":"Keep commentary after the final answer","text_hash":"febdff0791d1ceeddb2b7596da8282f4dcfd5cff4cad11dea4c0a23944cd8ffe","tgt_lang":"es","translated":"Mantener los comentarios después de la respuesta final","updated_at":"2026-07-01T01:06:39.912Z"} {"cache_key":"2bc6ac4dce823273f77abe66c28e3e51e6d814f2ee44d288d3f55ef35a4e5656","model":"claude-opus-4-8","provider":"anthropic","segment_id":"skillWorkshop.header.useCurrentChat","source_path":"ui/src/i18n/locales/es.ts","src_lang":"en","text":"Use current chat","text_hash":"fbc1ffd63daa506e927c7a85f6e43acd11e0b8c9f52a3951fc782b236ce9a787","tgt_lang":"es","translated":"Usar el chat actual","updated_at":"2026-06-16T14:14:11.214Z"} {"cache_key":"2f707cdc5fe0e4fc554806b712d898b35b2b1325ca6aebddfde0a0aee28905f8","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.dependenciesReady","source_path":"ui/src/i18n/locales/es.ts","src_lang":"en","text":"{count} ready","text_hash":"f5f5fd424d7c18f19a51ee147857efddc320a0ec6e1eeb4354be129425632f05","tgt_lang":"es","translated":"{count} listas","updated_at":"2026-06-16T14:14:17.778Z"} {"cache_key":"30934b4ae099ddda1d297ca871fb752e63f1606ac20f87b5a0a68d470aa1204b","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.detailAutomation","source_path":"ui/src/i18n/locales/es.ts","src_lang":"en","text":"Automation","text_hash":"d909750b1bbb71a39b6330ba8f81f4f8f6e889ed96d7ab366e74857909750c64","tgt_lang":"es","translated":"Automatización","updated_at":"2026-06-16T14:14:11.215Z"} @@ -43,6 +44,7 @@ {"cache_key":"51393930b033f6a7a118fcc23fca6d247bf5c89c0d71c9366dde265e4b8a6630","model":"claude-opus-4-8","provider":"anthropic","segment_id":"skillWorkshop.header.useCurrentChatAria","source_path":"ui/src/i18n/locales/es.ts","src_lang":"en","text":"Use current chat for revision requests","text_hash":"9c551a423ae74aedaaa90e4df9899dbdc02f846d6ee058bf2576a812e2c52119","tgt_lang":"es","translated":"Usar el chat actual para las solicitudes de revisión","updated_at":"2026-06-16T14:14:11.214Z"} {"cache_key":"51d0fe0c003bc4f1c282463c6e237c6fa411e90d0dc6046e0a57014bce6af45f","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.viewAll","source_path":"ui/src/i18n/locales/es.ts","src_lang":"en","text":"All cards","text_hash":"2306120917506b5998ec702f3661068b102dac538ba4c9e4634d65fe33eea98a","tgt_lang":"es","translated":"Todas las tarjetas","updated_at":"2026-06-17T14:14:08.769Z"} {"cache_key":"5374b6991e3457597aa34c1ac94740da287f12cb2241f61c42acbfee762a8997","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.artifacts","source_path":"ui/src/i18n/locales/es.ts","src_lang":"en","text":"Artifacts","text_hash":"314ae71b8c8dc9c952f0ffc58e35e6d9a41b5cf4756471c7cab0c9476cd5d20b","tgt_lang":"es","translated":"Artefactos","updated_at":"2026-06-16T14:14:23.912Z"} +{"cache_key":"53a39119b041c8b526ef9cf2fae89094f1299c8f5e3772df4dab61042c3f4518","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.commentaryLabel","source_path":"ui/src/i18n/locales/es.ts","src_lang":"en","text":"Commentary","text_hash":"4a7a75ab79cde05b0b1baa8f7a704c991586ea44d7e7793e57178ef56f778ff8","tgt_lang":"es","translated":"Comentarios","updated_at":"2026-07-01T01:06:39.912Z"} {"cache_key":"58020a74f665c649c802f71d76b737c7d876b96fd95acbe932a5a73db85a1a6d","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.viewMissingProof","source_path":"ui/src/i18n/locales/es.ts","src_lang":"en","text":"Missing proof","text_hash":"b46debe888e32eec183dc5936c79d22ea43bec580c410c2b3c1aa24aaa75d677","tgt_lang":"es","translated":"Sin comprobante","updated_at":"2026-06-17T14:14:08.769Z"} {"cache_key":"5d3b656957833268e13752ae570040470f719d92fc1d45948503ac0bf86e3eef","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.healthFailedAttempts","source_path":"ui/src/i18n/locales/es.ts","src_lang":"en","text":"failed attempts","text_hash":"fd9023af0795825a458100ddbe894a7a8f603324a2b7ad2305d4c9d2334cbd26","tgt_lang":"es","translated":"intentos fallidos","updated_at":"2026-06-17T14:14:13.986Z"} {"cache_key":"5fa5f353c2dd6b0a8085e1f7e44c161a8ce5765c6cfea51dd771218c6123ce2f","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.detailAutomationTenant","source_path":"ui/src/i18n/locales/es.ts","src_lang":"en","text":"Tenant: {tenant}","text_hash":"e896dc96a6847d7aaa593069e890e7a712fd60d7be60280ee24e1942e10411b0","tgt_lang":"es","translated":"Tenant: {tenant}","updated_at":"2026-06-16T14:14:11.215Z"} diff --git a/ui/src/i18n/locales/es.ts b/ui/src/i18n/locales/es.ts index dc1ce06df21c..e275f91595bd 100644 --- a/ui/src/i18n/locales/es.ts +++ b/ui/src/i18n/locales/es.ts @@ -1337,8 +1337,8 @@ export const es: TranslationMap = { settings: "Configuración del chat", thinkingToggle: "Alternar salida de pensamiento/trabajo del asistente", toolCallsToggle: "Alternar llamadas a herramientas y resultados de herramientas", - commentaryToggle: "Keep commentary after the final answer", - commentaryLabel: "Commentary", + commentaryToggle: "Mantener los comentarios después de la respuesta final", + commentaryLabel: "Comentarios", autoScrollMode: "Modo de desplazamiento automático", autoScrollAlways: "Siempre", autoScrollNearBottom: "Cerca del final", From 6bdffa501bcf8eb1b3b13122b6e2e76149693f07 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 01:06:54 +0000 Subject: [PATCH 05/29] chore(ui): refresh ja-JP control ui locale --- ui/src/i18n/.i18n/ja-JP.meta.json | 9 +++------ ui/src/i18n/.i18n/ja-JP.tm.jsonl | 2 ++ ui/src/i18n/locales/ja-JP.ts | 4 ++-- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/ui/src/i18n/.i18n/ja-JP.meta.json b/ui/src/i18n/.i18n/ja-JP.meta.json index 6899e28659d7..96f4482d6d05 100644 --- a/ui/src/i18n/.i18n/ja-JP.meta.json +++ b/ui/src/i18n/.i18n/ja-JP.meta.json @@ -1,14 +1,11 @@ { - "fallbackKeys": [ - "chat.commentaryLabel", - "chat.commentaryToggle" - ], - "generatedAt": "2026-06-30T23:31:03.238Z", + "fallbackKeys": [], + "generatedAt": "2026-07-01T01:06:53.052Z", "locale": "ja-JP", "model": "claude-opus-4-8", "provider": "anthropic", "sourceHash": "2efa88c26ff88470a76b18ae218dd9c325165f98587f359ed51dbfb51a7d8428", "totalKeys": 1418, - "translatedKeys": 1416, + "translatedKeys": 1418, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/ja-JP.tm.jsonl b/ui/src/i18n/.i18n/ja-JP.tm.jsonl index 7154bbb1e1a4..d365ae592cf0 100644 --- a/ui/src/i18n/.i18n/ja-JP.tm.jsonl +++ b/ui/src/i18n/.i18n/ja-JP.tm.jsonl @@ -50,6 +50,7 @@ {"cache_key":"680d8cfcc46f5bf79ff5ea1b57b5f7fed47326f0aff56b5e3709fa088fd1bb2c","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.viewReady","source_path":"ui/src/i18n/locales/ja-JP.ts","src_lang":"en","text":"Ready","text_hash":"5fa7aac5375c5815787fba3f49559f9b45b14023147ce0652803387974144e5f","tgt_lang":"ja-JP","translated":"準備完了","updated_at":"2026-06-17T14:14:04.173Z"} {"cache_key":"6e97dbe68af401d3c59bd5ab97cf295a1aa5a0584b20f19bf841eb9be3d54866","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.artifactCount","source_path":"ui/src/i18n/locales/ja-JP.ts","src_lang":"en","text":"{count} artifacts","text_hash":"022b6b55a10f1864b7aff7c307e49ab37e11e6999111fb87349f040d100abba3","tgt_lang":"ja-JP","translated":"{count} 件の成果物","updated_at":"2026-06-16T14:14:19.257Z"} {"cache_key":"6fd643605be97b117d68111ea4ed6d6da05c0ce7f2880df9b15e63d79be3411d","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.noBrowserFiles","source_path":"ui/src/i18n/locales/ja-JP.ts","src_lang":"en","text":"No files in this folder.","text_hash":"3847020c79b1c74e28aa550f0ae53838b764e87f1daf1480dd6aae45ae0529d6","tgt_lang":"ja-JP","translated":"このフォルダーにファイルはありません。","updated_at":"2026-06-16T14:14:19.257Z"} +{"cache_key":"6fd82965e1b9919018ec76c9379372b26fc4e633cab0f1c72647895a671fdf45","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.commentaryLabel","source_path":"ui/src/i18n/locales/ja-JP.ts","src_lang":"en","text":"Commentary","text_hash":"4a7a75ab79cde05b0b1baa8f7a704c991586ea44d7e7793e57178ef56f778ff8","tgt_lang":"ja-JP","translated":"コメント","updated_at":"2026-07-01T01:06:53.045Z"} {"cache_key":"7032ea193e157bd2e91c64f3fc49a6f9711b68e0495bb57c71b650f95bbcc194","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.healthLabel","source_path":"ui/src/i18n/locales/ja-JP.ts","src_lang":"en","text":"Workboard health","text_hash":"85416c4a6d64e35611bdd9747b82815936c38b41d820796ba1fbfbb7539d906b","tgt_lang":"ja-JP","translated":"ワークボードの状態","updated_at":"2026-06-17T14:14:09.255Z"} {"cache_key":"72c184ed5db446880bf6d56893c5f172b86ebd075fac9a236af5af6e86d2e941","model":"gpt-5.5","provider":"openai","segment_id":"languages.ru","source_path":"ui/src/i18n/locales/ja-JP.ts","src_lang":"en","text":"Русский (Russian)","text_hash":"ea81bf0fd46410b501bddee074ab6f00b0cdf377a6cafe608dcf2c28f7cb2f4e","tgt_lang":"ja-JP","translated":"Русский (ロシア語)","updated_at":"2026-06-26T21:43:27.880Z"} {"cache_key":"7a805e49dd5b20f9b92d98c5001a31aa3eb357c6eaee8a9819e881abb4999e9a","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.autoRefresh5s","source_path":"ui/src/i18n/locales/ja-JP.ts","src_lang":"en","text":"5s","text_hash":"93e3d8c5b10657d2884f177488b689aadf82a83f962237cb602b3314386ab3b7","tgt_lang":"ja-JP","translated":"5秒","updated_at":"2026-06-17T14:14:09.255Z"} @@ -66,6 +67,7 @@ {"cache_key":"8ccfbd67b038beb504411a4aad18dabf77d1c9aef64db7f85528256739135dc2","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.detailAutomationSkills","source_path":"ui/src/i18n/locales/ja-JP.ts","src_lang":"en","text":"Skills: {skills}","text_hash":"4788d5f9db66e1421a762bbd942c64450c73d2145a6ef929ce32a919a0f2e3a1","tgt_lang":"ja-JP","translated":"Skills: {skills}","updated_at":"2026-06-16T14:14:04.101Z"} {"cache_key":"8dfc5efe84dea73600d09ebc9284881470867cc6bf5a9ab9846ee4614af3f69f","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.detailAutomationWorkspace","source_path":"ui/src/i18n/locales/ja-JP.ts","src_lang":"en","text":"Workspace: {workspace}","text_hash":"17f5e696e557a646a9003fc8448f6f6761f5fe6bdf7478f750f471496e87c17b","tgt_lang":"ja-JP","translated":"ワークスペース: {workspace}","updated_at":"2026-06-16T14:14:04.101Z"} {"cache_key":"8f13222e40ef70c72d03d866d5802e71b1b9901160007d84669961432e0d2681","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.dependencyMissing","source_path":"ui/src/i18n/locales/ja-JP.ts","src_lang":"en","text":"{parent} (missing)","text_hash":"8daa419059727391c01e3b7021e05d8d70b4da67f9c57cd2d80f302af77aac53","tgt_lang":"ja-JP","translated":"{parent}(欠落)","updated_at":"2026-06-16T14:14:13.256Z"} +{"cache_key":"8fb588b6b5e3c2da65abd9016c94517733e86b4a3286dfed1b05bc71a89637f3","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.commentaryToggle","source_path":"ui/src/i18n/locales/ja-JP.ts","src_lang":"en","text":"Keep commentary after the final answer","text_hash":"febdff0791d1ceeddb2b7596da8282f4dcfd5cff4cad11dea4c0a23944cd8ffe","tgt_lang":"ja-JP","translated":"最終回答の後にコメントを残す","updated_at":"2026-07-01T01:06:53.045Z"} {"cache_key":"9182bd9f8c396e9816aeac9e137bd8bae0d451e4ec8fb9139032491c4e5d5354","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.healthStale","source_path":"ui/src/i18n/locales/ja-JP.ts","src_lang":"en","text":"stale","text_hash":"a03f2386ae06b21109577020844df367857b72c2fcce384c1896fed98a89c82b","tgt_lang":"ja-JP","translated":"古い","updated_at":"2026-06-17T14:14:09.255Z"} {"cache_key":"93309fb097ac6115ffa01bb8a34c155ed43cae5a9c78fc137d45bb1d1bd13c0a","model":"claude-opus-4-8","provider":"anthropic","segment_id":"skillWorkshop.header.useCurrentChatTooltip","source_path":"ui/src/i18n/locales/ja-JP.ts","src_lang":"en","text":"Send revision requests to the current chat session instead of the proposal's workshop session.","text_hash":"9db782d40e88750d4faed33c8a73c24552070f101483881c60af8cf446c674a6","tgt_lang":"ja-JP","translated":"修正リクエストを提案のワークショップセッションではなく現在のチャットセッションに送信します。","updated_at":"2026-06-16T14:14:04.101Z"} {"cache_key":"93aa12fedaab987305f07af92bc0269591826006609125f17a2dc90afd7e6db8","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.detailNotePlaceholder","source_path":"ui/src/i18n/locales/ja-JP.ts","src_lang":"en","text":"Add a decision, blocker, or proof note...","text_hash":"0e40ea8371be2fcbd8379458b0da541ca0dce5dc86357dea64a4d8fac1c742dc","tgt_lang":"ja-JP","translated":"決定事項、ブロッカー、証跡メモを追加...","updated_at":"2026-06-16T14:14:13.256Z"} diff --git a/ui/src/i18n/locales/ja-JP.ts b/ui/src/i18n/locales/ja-JP.ts index d02c3422b4a0..1b4e1cbaa7bb 100644 --- a/ui/src/i18n/locales/ja-JP.ts +++ b/ui/src/i18n/locales/ja-JP.ts @@ -1334,8 +1334,8 @@ export const ja_JP: TranslationMap = { settings: "チャット設定", thinkingToggle: "アシスタントの思考 / 作業出力の表示を切り替え", toolCallsToggle: "ツール呼び出しとツール結果の表示を切り替え", - commentaryToggle: "Keep commentary after the final answer", - commentaryLabel: "Commentary", + commentaryToggle: "最終回答の後にコメントを残す", + commentaryLabel: "コメント", autoScrollMode: "自動スクロールモード", autoScrollAlways: "常に", autoScrollNearBottom: "下部付近", From b4476ab72f9d74a946ef0cc963e4d26dab280e45 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 01:07:04 +0000 Subject: [PATCH 06/29] chore(ui): refresh ko control ui locale --- ui/src/i18n/.i18n/ko.meta.json | 9 +++------ ui/src/i18n/.i18n/ko.tm.jsonl | 2 ++ ui/src/i18n/locales/ko.ts | 4 ++-- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/ui/src/i18n/.i18n/ko.meta.json b/ui/src/i18n/.i18n/ko.meta.json index 4b6709d70c5d..2107406961ef 100644 --- a/ui/src/i18n/.i18n/ko.meta.json +++ b/ui/src/i18n/.i18n/ko.meta.json @@ -1,14 +1,11 @@ { - "fallbackKeys": [ - "chat.commentaryLabel", - "chat.commentaryToggle" - ], - "generatedAt": "2026-06-30T23:31:03.343Z", + "fallbackKeys": [], + "generatedAt": "2026-07-01T01:07:03.501Z", "locale": "ko", "model": "claude-opus-4-8", "provider": "anthropic", "sourceHash": "2efa88c26ff88470a76b18ae218dd9c325165f98587f359ed51dbfb51a7d8428", "totalKeys": 1418, - "translatedKeys": 1416, + "translatedKeys": 1418, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/ko.tm.jsonl b/ui/src/i18n/.i18n/ko.tm.jsonl index 6c147d4ef36f..5df526e2ba04 100644 --- a/ui/src/i18n/.i18n/ko.tm.jsonl +++ b/ui/src/i18n/.i18n/ko.tm.jsonl @@ -23,6 +23,7 @@ {"cache_key":"2d50f5d9b44d9abf6dc925e4af0d1212acb9f84a5052fb6e2b80978c1d47183e","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.dependenciesBlocked","source_path":"ui/src/i18n/locales/ko.ts","src_lang":"en","text":"{count} blocked","text_hash":"fb39869b0fb3b8933126014e5c3739d7d67a620b8369781ca27e7395c595bde8","tgt_lang":"ko","translated":"{count}개 차단됨","updated_at":"2026-06-16T14:14:28.718Z"} {"cache_key":"2d7237d44831dff3698d2a1fd23359711ff127a9311fa156411831c9ecb7f5e0","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.detailWorkerProtocol","source_path":"ui/src/i18n/locales/ko.ts","src_lang":"en","text":"Worker protocol","text_hash":"e445d823cfa48c4e8fa1d8854771e9939955e772428be6d7957deec0f7968764","tgt_lang":"ko","translated":"워커 프로토콜","updated_at":"2026-06-16T14:14:21.259Z"} {"cache_key":"2d9034353b9ca09fdca8f1c7b7e5ac0b811ca90e07a857e11ebbd125c9c84fb4","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.workspace","source_path":"ui/src/i18n/locales/ko.ts","src_lang":"en","text":"Session","text_hash":"6959b4159575d8dd76d9f3bbe2c6437904f861e7860c35abd18deffb1c3425a0","tgt_lang":"ko","translated":"세션","updated_at":"2026-06-16T14:14:28.718Z"} +{"cache_key":"2e9120864871002ecd97c52e567ae68a4ac853832c4cf5e9afe60541ea26ec38","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.commentaryToggle","source_path":"ui/src/i18n/locales/ko.ts","src_lang":"en","text":"Keep commentary after the final answer","text_hash":"febdff0791d1ceeddb2b7596da8282f4dcfd5cff4cad11dea4c0a23944cd8ffe","tgt_lang":"ko","translated":"최종 답변 후에도 해설 유지","updated_at":"2026-07-01T01:07:03.490Z"} {"cache_key":"2ebc9ec7f5279cb88fefee4ac4ed40d4e8443ac52f685d25798f9ff7b87866bf","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.layoutCompact","source_path":"ui/src/i18n/locales/ko.ts","src_lang":"en","text":"Compact card density","text_hash":"2f532993d5a6ccda4d758c7ecdda8bebaa857218045a3d4a011fec73d9728785","tgt_lang":"ko","translated":"조밀한 카드 밀도","updated_at":"2026-06-17T14:14:15.283Z"} {"cache_key":"30f06eab41f8027eb5af2240933564f7569ea9101368a362aa8dd49043fa0e20","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.viewAll","source_path":"ui/src/i18n/locales/ko.ts","src_lang":"en","text":"All cards","text_hash":"2306120917506b5998ec702f3661068b102dac538ba4c9e4634d65fe33eea98a","tgt_lang":"ko","translated":"모든 카드","updated_at":"2026-06-17T14:14:15.283Z"} {"cache_key":"3185b58d9c305e7ea8830933f98dc679a5185831c373f5aa2410e806df460021","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.healthLabel","source_path":"ui/src/i18n/locales/ko.ts","src_lang":"en","text":"Workboard health","text_hash":"85416c4a6d64e35611bdd9747b82815936c38b41d820796ba1fbfbb7539d906b","tgt_lang":"ko","translated":"워크보드 상태","updated_at":"2026-06-17T14:14:20.553Z"} @@ -43,6 +44,7 @@ {"cache_key":"50cdc24b1d53cce7d0bd83246728b371b91de9dc4f2291a8655f7ece66a36aa7","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.parentFolder","source_path":"ui/src/i18n/locales/ko.ts","src_lang":"en","text":"Parent folder","text_hash":"158f5a01ef8cfb1e6d91f8c321dd3a63f5e457f9650eecd662857701762bd31d","tgt_lang":"ko","translated":"상위 폴더","updated_at":"2026-06-16T14:14:34.760Z"} {"cache_key":"5281910621ef1e54d8f9540eb7d5da3b2b0d951536c8968e4a78ebd6deab74ca","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.viewBlocked","source_path":"ui/src/i18n/locales/ko.ts","src_lang":"en","text":"Blocked","text_hash":"18f2a0947f9d6523991b29b450307f22773f57d65f7efb98d48a167df04d6b1d","tgt_lang":"ko","translated":"차단됨","updated_at":"2026-06-17T14:14:15.283Z"} {"cache_key":"528bad7905be8ed83dd6bb84ee174c4811e6203f05d6c863832840f5852d64b4","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.expand","source_path":"ui/src/i18n/locales/ko.ts","src_lang":"en","text":"Expand session workspace","text_hash":"ac1d210db40c5026879774849ad74a9e1247523192a795ac33965b3ee72691c2","tgt_lang":"ko","translated":"세션 작업 공간 펼치기","updated_at":"2026-06-16T14:14:28.718Z"} +{"cache_key":"529857a2f1f753c38515068a98421f8f6def577b86dad9a027db156719297e7e","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.commentaryLabel","source_path":"ui/src/i18n/locales/ko.ts","src_lang":"en","text":"Commentary","text_hash":"4a7a75ab79cde05b0b1baa8f7a704c991586ea44d7e7793e57178ef56f778ff8","tgt_lang":"ko","translated":"해설","updated_at":"2026-07-01T01:07:03.490Z"} {"cache_key":"52c5ec56f238865a0c59aef65f2420bf688c6adbfe78403d315a80274da97284","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.healthRunning","source_path":"ui/src/i18n/locales/ko.ts","src_lang":"en","text":"running","text_hash":"c071cf5f5ed6f884cc70155b6f05f755fd46a302d05e4261b7e92ce878bbfed8","tgt_lang":"ko","translated":"실행 중","updated_at":"2026-06-17T14:14:20.553Z"} {"cache_key":"537780f08d7b50c843c55e01e211ea8bdef2dd06078cfc262ee3642f0391b5ac","model":"claude-opus-4-8","provider":"anthropic","segment_id":"skillWorkshop.header.useCurrentChatTooltip","source_path":"ui/src/i18n/locales/ko.ts","src_lang":"en","text":"Send revision requests to the current chat session instead of the proposal's workshop session.","text_hash":"9db782d40e88750d4faed33c8a73c24552070f101483881c60af8cf446c674a6","tgt_lang":"ko","translated":"수정 요청을 제안의 워크숍 세션 대신 현재 채팅 세션으로 보냅니다.","updated_at":"2026-06-16T14:14:21.259Z"} {"cache_key":"5421cb10943d8f506dae7f617c0f3880ae8162748de416ba11b1f1e47712c628","model":"gpt-5.5","provider":"openai","segment_id":"workboard.eventOrchestration","source_path":"ui/src/i18n/locales/ko.ts","src_lang":"en","text":"Orchestration","text_hash":"ed4fdd1721677737cffb2862fe34d5b63901c7cc76b8c67c51e92a467b31a5e7","tgt_lang":"ko","translated":"오케스트레이션","updated_at":"2026-05-30T15:38:20.918Z"} diff --git a/ui/src/i18n/locales/ko.ts b/ui/src/i18n/locales/ko.ts index 156bec1e027c..9115d0108cf5 100644 --- a/ui/src/i18n/locales/ko.ts +++ b/ui/src/i18n/locales/ko.ts @@ -1320,8 +1320,8 @@ export const ko: TranslationMap = { settings: "채팅 설정", thinkingToggle: "어시스턴트 생각/작업 출력 전환", toolCallsToggle: "도구 호출 및 도구 결과 전환", - commentaryToggle: "Keep commentary after the final answer", - commentaryLabel: "Commentary", + commentaryToggle: "최종 답변 후에도 해설 유지", + commentaryLabel: "해설", autoScrollMode: "자동 스크롤 모드", autoScrollAlways: "항상", autoScrollNearBottom: "하단 근처", From 5b5c6237c236b79a894c6d2c1dd4d3bd7e5188f3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 01:07:08 +0000 Subject: [PATCH 07/29] chore(ui): refresh fr control ui locale --- ui/src/i18n/.i18n/fr.meta.json | 9 +++------ ui/src/i18n/.i18n/fr.tm.jsonl | 2 ++ ui/src/i18n/locales/fr.ts | 4 ++-- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/ui/src/i18n/.i18n/fr.meta.json b/ui/src/i18n/.i18n/fr.meta.json index dd8e2e67b673..d26d6786f7eb 100644 --- a/ui/src/i18n/.i18n/fr.meta.json +++ b/ui/src/i18n/.i18n/fr.meta.json @@ -1,14 +1,11 @@ { - "fallbackKeys": [ - "chat.commentaryLabel", - "chat.commentaryToggle" - ], - "generatedAt": "2026-06-30T23:31:03.446Z", + "fallbackKeys": [], + "generatedAt": "2026-07-01T01:07:07.541Z", "locale": "fr", "model": "claude-opus-4-8", "provider": "anthropic", "sourceHash": "2efa88c26ff88470a76b18ae218dd9c325165f98587f359ed51dbfb51a7d8428", "totalKeys": 1418, - "translatedKeys": 1416, + "translatedKeys": 1418, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/fr.tm.jsonl b/ui/src/i18n/.i18n/fr.tm.jsonl index 6e52894d5297..65472b985887 100644 --- a/ui/src/i18n/.i18n/fr.tm.jsonl +++ b/ui/src/i18n/.i18n/fr.tm.jsonl @@ -16,6 +16,7 @@ {"cache_key":"29543289b89e37bfcf5463f542622dbc78302bfc7b168de33bc9c12a4a75a80b","model":"gpt-5.5","provider":"openai","segment_id":"subtitles.skillWorkshop","source_path":"ui/src/i18n/locales/fr.ts","src_lang":"en","text":"Review, refine, and apply proposals before they become live skills.","text_hash":"f907c72e9f18a205027257cd6fecdd52b03732227a17dcec0db038e11de3f8cc","tgt_lang":"fr","translated":"Examinez, affinez et appliquez les propositions avant qu’elles ne deviennent des skills actives.","updated_at":"2026-05-31T21:48:25.015Z"} {"cache_key":"32508a245b7f297458454d0d6b421f18bfb8e5b30cc485b6c8efb83efe906823","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.autoRefresh60s","source_path":"ui/src/i18n/locales/fr.ts","src_lang":"en","text":"60s","text_hash":"f79f071ab5b033ca8fb42c077f39708930d194b18f4608eb26ac1d9665a8836f","tgt_lang":"fr","translated":"60s","updated_at":"2026-06-17T14:14:37.466Z"} {"cache_key":"33f08936ac956a25ff1afbce1f6967a17269c18d5c829f5fc007dde85c338703","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.viewAll","source_path":"ui/src/i18n/locales/fr.ts","src_lang":"en","text":"All cards","text_hash":"2306120917506b5998ec702f3661068b102dac538ba4c9e4634d65fe33eea98a","tgt_lang":"fr","translated":"Toutes les cartes","updated_at":"2026-06-17T14:14:32.183Z"} +{"cache_key":"35481b71eba2317119d4dbf7dbed0f878b2be2d53e7b666729eeed5f8c10d827","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.commentaryLabel","source_path":"ui/src/i18n/locales/fr.ts","src_lang":"en","text":"Commentary","text_hash":"4a7a75ab79cde05b0b1baa8f7a704c991586ea44d7e7793e57178ef56f778ff8","tgt_lang":"fr","translated":"Commentaire","updated_at":"2026-07-01T01:07:07.533Z"} {"cache_key":"3bc46432de64f9d82a3e78b6cbc8170448add64ef00783823c1f0266d24ad3b8","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.agentFilterConfiguredDefaultHelp","source_path":"ui/src/i18n/locales/fr.ts","src_lang":"en","text":"Cards explicitly assigned to the configured default agent.","text_hash":"9bb80530da1dfd473936d94642b83cc668b7362cb65675a565f17569937af92f","tgt_lang":"fr","translated":"Cartes explicitement assignées à l'agent par défaut configuré.","updated_at":"2026-06-17T14:14:32.183Z"} {"cache_key":"3c0184dd360912c8947b9a78652bf0b39064c358953f3403ff700329ef79a36f","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.healthLabel","source_path":"ui/src/i18n/locales/fr.ts","src_lang":"en","text":"Workboard health","text_hash":"85416c4a6d64e35611bdd9747b82815936c38b41d820796ba1fbfbb7539d906b","tgt_lang":"fr","translated":"État du tableau de travail","updated_at":"2026-06-17T14:14:37.466Z"} {"cache_key":"3ced3592ea6acebeac1c5b2e06684c1aabd1fdc1deed38906e62320a9fe51bd4","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.agentFilterConfiguredDefault","source_path":"ui/src/i18n/locales/fr.ts","src_lang":"en","text":"{agent} (default)","text_hash":"7e996234f0fa55605720f9dc954a58411795bd882e948c87c739d43bd02137c3","tgt_lang":"fr","translated":"{agent} (par défaut)","updated_at":"2026-06-17T14:14:32.183Z"} @@ -88,6 +89,7 @@ {"cache_key":"c85905c4a1943074a8ab1f9268d4ab9d93fd4f812cdf2c024b881f003d746cfa","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.autoRefresh5s","source_path":"ui/src/i18n/locales/fr.ts","src_lang":"en","text":"5s","text_hash":"93e3d8c5b10657d2884f177488b689aadf82a83f962237cb602b3314386ab3b7","tgt_lang":"fr","translated":"5s","updated_at":"2026-06-17T14:14:37.466Z"} {"cache_key":"c887bbafc896df50744c8745177bdd449c1e66a8962c65af5b22e8f3221ac9d2","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.read","source_path":"ui/src/i18n/locales/fr.ts","src_lang":"en","text":"Read","text_hash":"9b9a8d05a7ec353bda84f9c1bb3178c299de3001b5e970508ddc889c487f92ca","tgt_lang":"fr","translated":"Lu","updated_at":"2026-06-16T14:14:38.496Z"} {"cache_key":"ca4f3560f1c3259a3c8c58d0151768624f085c2bfbf4d48f3c30d2fe47fb794c","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.lastRefreshed","source_path":"ui/src/i18n/locales/fr.ts","src_lang":"en","text":"Updated {time}","text_hash":"2f87419441e6111b4d62893d3c4ef5ddeb2c8e1af82fabab6132856faf77f907","tgt_lang":"fr","translated":"Mis à jour {time}","updated_at":"2026-06-17T14:14:37.466Z"} +{"cache_key":"cb49264e87f513faec8b9301db790ce23fef424469204a743337e52a199f291e","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.commentaryToggle","source_path":"ui/src/i18n/locales/fr.ts","src_lang":"en","text":"Keep commentary after the final answer","text_hash":"febdff0791d1ceeddb2b7596da8282f4dcfd5cff4cad11dea4c0a23944cd8ffe","tgt_lang":"fr","translated":"Conserver le commentaire après la réponse finale","updated_at":"2026-07-01T01:07:07.533Z"} {"cache_key":"ceea53168997ca7b65bf9974ae044a3bfb38b661e2cb54a1ef1a5e9e7f99b9ec","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.viewRunning","source_path":"ui/src/i18n/locales/fr.ts","src_lang":"en","text":"Running","text_hash":"f4ccae29e1bb0c20a124570a1b43f4347ea94bba9f84ffdfddd9c7445b126128","tgt_lang":"fr","translated":"En cours","updated_at":"2026-06-17T14:14:32.183Z"} {"cache_key":"cf26b76ef0c037d17852ff2a01ea528d6cf86c0b5c6856c2b91db0abaac57de6","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.detailUpdatedValue","source_path":"ui/src/i18n/locales/fr.ts","src_lang":"en","text":"Updated: {time}","text_hash":"5e72d5445f018c9d08aa34ae0178fb9aa49eea6a0afd0c8d379f20b7af3e8aa0","tgt_lang":"fr","translated":"Mis à jour : {time}","updated_at":"2026-06-16T14:14:26.985Z"} {"cache_key":"d0350ed07e280443fb772ba1be389039f10902a99239608766e4ab55b84e69ad","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.files","source_path":"ui/src/i18n/locales/fr.ts","src_lang":"en","text":"Workspace","text_hash":"87bb59ba2f92f2a5a9f13e021fd58dd14ae5c065b1046146875e6e68d5ebc8b7","tgt_lang":"fr","translated":"Espace de travail","updated_at":"2026-06-16T14:14:33.243Z"} diff --git a/ui/src/i18n/locales/fr.ts b/ui/src/i18n/locales/fr.ts index 3866309ccf99..1af3b2e68e2f 100644 --- a/ui/src/i18n/locales/fr.ts +++ b/ui/src/i18n/locales/fr.ts @@ -1344,8 +1344,8 @@ export const fr: TranslationMap = { settings: "Paramètres de chat", thinkingToggle: "Afficher/masquer la sortie de réflexion/travail de l’assistant", toolCallsToggle: "Afficher/masquer les appels d’outil et les résultats d’outil", - commentaryToggle: "Keep commentary after the final answer", - commentaryLabel: "Commentary", + commentaryToggle: "Conserver le commentaire après la réponse finale", + commentaryLabel: "Commentaire", autoScrollMode: "Mode de défilement automatique", autoScrollAlways: "Toujours", autoScrollNearBottom: "Près du bas", From 1520d0980741c85407c4e2037dfd5fb9d8f9d1c4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 01:07:20 +0000 Subject: [PATCH 08/29] chore(ui): refresh ar control ui locale --- ui/src/i18n/.i18n/ar.meta.json | 9 +++------ ui/src/i18n/.i18n/ar.tm.jsonl | 2 ++ ui/src/i18n/locales/ar.ts | 4 ++-- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/ui/src/i18n/.i18n/ar.meta.json b/ui/src/i18n/.i18n/ar.meta.json index 60be2660cfb8..9de857beab47 100644 --- a/ui/src/i18n/.i18n/ar.meta.json +++ b/ui/src/i18n/.i18n/ar.meta.json @@ -1,14 +1,11 @@ { - "fallbackKeys": [ - "chat.commentaryLabel", - "chat.commentaryToggle" - ], - "generatedAt": "2026-06-30T23:31:03.664Z", + "fallbackKeys": [], + "generatedAt": "2026-07-01T01:07:19.847Z", "locale": "ar", "model": "claude-opus-4-8", "provider": "anthropic", "sourceHash": "2efa88c26ff88470a76b18ae218dd9c325165f98587f359ed51dbfb51a7d8428", "totalKeys": 1418, - "translatedKeys": 1416, + "translatedKeys": 1418, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/ar.tm.jsonl b/ui/src/i18n/.i18n/ar.tm.jsonl index b3b638f73358..f646d9487aed 100644 --- a/ui/src/i18n/.i18n/ar.tm.jsonl +++ b/ui/src/i18n/.i18n/ar.tm.jsonl @@ -7,6 +7,7 @@ {"cache_key":"1b701a3f34630b2c5cf2026b3ca35588ad3a76f0999436f126779e39c51e420d","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.artifacts","source_path":"ui/src/i18n/locales/ar.ts","src_lang":"en","text":"Artifacts","text_hash":"314ae71b8c8dc9c952f0ffc58e35e6d9a41b5cf4756471c7cab0c9476cd5d20b","tgt_lang":"ar","translated":"العناصر","updated_at":"2026-06-16T14:15:41.761Z"} {"cache_key":"1bb67bd03a5259003cb18979044f40ee96d0b50a449e65065f33ddb5ba15a1fa","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.agentFilterUnassignedHelp","source_path":"ui/src/i18n/locales/ar.ts","src_lang":"en","text":"Cards without an explicit agent.","text_hash":"f716a36252b33511df056fe7d1092be598eca17ea76bedc5d6d3532ec6b0ffea","tgt_lang":"ar","translated":"البطاقات بدون وكيل محدد.","updated_at":"2026-06-17T14:14:58.728Z"} {"cache_key":"1de1f729171e18967f76a207f15eaca44b44fa087edb9eb78548516b09ff2197","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.artifactCount","source_path":"ui/src/i18n/locales/ar.ts","src_lang":"en","text":"{count} artifacts","text_hash":"022b6b55a10f1864b7aff7c307e49ab37e11e6999111fb87349f040d100abba3","tgt_lang":"ar","translated":"{count} عنصرًا","updated_at":"2026-06-16T14:15:41.761Z"} +{"cache_key":"20b2e88b33793df99629daf65b657cb666cb9b4a71ea30e2c05f943939c49565","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.commentaryLabel","source_path":"ui/src/i18n/locales/ar.ts","src_lang":"en","text":"Commentary","text_hash":"4a7a75ab79cde05b0b1baa8f7a704c991586ea44d7e7793e57178ef56f778ff8","tgt_lang":"ar","translated":"التعليق","updated_at":"2026-07-01T01:07:19.841Z"} {"cache_key":"20f98bac595afb3715bf5acbd1e087d08570280150e8d7e0649e378a98ac0489","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.noSearchResults","source_path":"ui/src/i18n/locales/ar.ts","src_lang":"en","text":"No matching files.","text_hash":"6ba2ede6c6019b640f63e7e48c5ee8238e701c6e539ce9abb5a7a9d9c71d8a73","tgt_lang":"ar","translated":"لا توجد ملفات مطابقة.","updated_at":"2026-06-16T14:15:41.761Z"} {"cache_key":"222a52155c00d9125472c178e688a19fba317aa89a0fabcd3e6a77c0b930f30c","model":"gpt-5.5","provider":"openai","segment_id":"workboard.eventProtocolViolation","source_path":"ui/src/i18n/locales/ar.ts","src_lang":"en","text":"Protocol violation","text_hash":"367bb2052963f7d75beb672d3ca0430d7d49ac48a2759d578c7df933178fe564","tgt_lang":"ar","translated":"انتهاك البروتوكول","updated_at":"2026-05-30T15:38:27.116Z"} {"cache_key":"28628884bf42001bb1106e9a421a05631fa1606a126aa1769ff909067eb3dfd8","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.layoutComfortable","source_path":"ui/src/i18n/locales/ar.ts","src_lang":"en","text":"Comfortable card density","text_hash":"bfaaf4553fd254bf24431ebabf62faebfd862685e9e7a52f5e799b11488dc7fe","tgt_lang":"ar","translated":"كثافة بطاقات مريحة","updated_at":"2026-06-17T14:14:58.728Z"} @@ -36,6 +37,7 @@ {"cache_key":"732ca472029046ca18ebf8398747ced1c60994e3c2a0555b164aa3a52730691b","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.viewBlocked","source_path":"ui/src/i18n/locales/ar.ts","src_lang":"en","text":"Blocked","text_hash":"18f2a0947f9d6523991b29b450307f22773f57d65f7efb98d48a167df04d6b1d","tgt_lang":"ar","translated":"محظور","updated_at":"2026-06-17T14:14:58.728Z"} {"cache_key":"73a457b2179107dd5d1426e0fec360f1cf9f00ec5ed7b3e77594575ef9660ad0","model":"gpt-5.5","provider":"openai","segment_id":"languages.hi","source_path":"ui/src/i18n/locales/ar.ts","src_lang":"en","text":"हिन्दी (Hindi)","text_hash":"fab2abfce45382f3031c59477017700a8cb5dfaf8d15379dc24304809b97c7d5","tgt_lang":"ar","translated":"الهندية (Hindi)","updated_at":"2026-06-26T21:43:32.403Z"} {"cache_key":"74cc623b957f70b35f6fa003d2b2f6cf8343bfa56087b0a673520cc111d226b6","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.viewAll","source_path":"ui/src/i18n/locales/ar.ts","src_lang":"en","text":"All cards","text_hash":"2306120917506b5998ec702f3661068b102dac538ba4c9e4634d65fe33eea98a","tgt_lang":"ar","translated":"جميع البطاقات","updated_at":"2026-06-17T14:14:58.728Z"} +{"cache_key":"7861b489b6754a2b4e68f72ce2d3c1c3a539bb8e2181026222020d021f025c72","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.commentaryToggle","source_path":"ui/src/i18n/locales/ar.ts","src_lang":"en","text":"Keep commentary after the final answer","text_hash":"febdff0791d1ceeddb2b7596da8282f4dcfd5cff4cad11dea4c0a23944cd8ffe","tgt_lang":"ar","translated":"الاحتفاظ بالتعليق بعد الإجابة النهائية","updated_at":"2026-07-01T01:07:19.841Z"} {"cache_key":"7d60d8554dcfb8d41137e8314ecceeffee430377085a746514a22d59c6140240","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.detailAutomationWorkspace","source_path":"ui/src/i18n/locales/ar.ts","src_lang":"en","text":"Workspace: {workspace}","text_hash":"17f5e696e557a646a9003fc8448f6f6761f5fe6bdf7478f750f471496e87c17b","tgt_lang":"ar","translated":"مساحة العمل: {workspace}","updated_at":"2026-06-16T14:15:27.039Z"} {"cache_key":"7e044004d8cce1e8fd0122556a521aa760fe712e2c33164c9e8ca17cbbcc2d25","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.viewPresetCount","source_path":"ui/src/i18n/locales/ar.ts","src_lang":"en","text":"{count} cards","text_hash":"4b3e5442ebd2f839d45fddf95b2c2a18427dbd6ac06c8b57f9d9e996dcb73607","tgt_lang":"ar","translated":"{count} بطاقة","updated_at":"2026-06-17T14:14:58.728Z"} {"cache_key":"7f4bc10589146ec18ab97233184abe2b6e5b31524437faf90919e5196a962ae4","model":"gpt-5.5","provider":"openai","segment_id":"workboard.badgeWorkerLogs","source_path":"ui/src/i18n/locales/ar.ts","src_lang":"en","text":"{count} worker logs","text_hash":"2923e2a84e6ed0ca048d280206198b156da6148859e474498d335a44e323e0a8","tgt_lang":"ar","translated":"{count} سجلات العامل","updated_at":"2026-05-30T15:38:27.116Z"} diff --git a/ui/src/i18n/locales/ar.ts b/ui/src/i18n/locales/ar.ts index fda2dbda00e4..2e1707ab4fd4 100644 --- a/ui/src/i18n/locales/ar.ts +++ b/ui/src/i18n/locales/ar.ts @@ -1313,8 +1313,8 @@ export const ar: TranslationMap = { settings: "إعدادات الدردشة", thinkingToggle: "تبديل مخرجات تفكير/عمل المساعد", toolCallsToggle: "تبديل استدعاءات الأدوات ونتائج الأدوات", - commentaryToggle: "Keep commentary after the final answer", - commentaryLabel: "Commentary", + commentaryToggle: "الاحتفاظ بالتعليق بعد الإجابة النهائية", + commentaryLabel: "التعليق", autoScrollMode: "وضع التمرير التلقائي", autoScrollAlways: "دائمًا", autoScrollNearBottom: "قرب الأسفل", From 614032676d16bf2dd98030e7925f14ad52f5618e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 01:07:32 +0000 Subject: [PATCH 09/29] chore(ui): refresh it control ui locale --- ui/src/i18n/.i18n/it.meta.json | 9 +++------ ui/src/i18n/.i18n/it.tm.jsonl | 2 ++ ui/src/i18n/locales/it.ts | 4 ++-- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/ui/src/i18n/.i18n/it.meta.json b/ui/src/i18n/.i18n/it.meta.json index 17b612432a4f..946915c3234f 100644 --- a/ui/src/i18n/.i18n/it.meta.json +++ b/ui/src/i18n/.i18n/it.meta.json @@ -1,14 +1,11 @@ { - "fallbackKeys": [ - "chat.commentaryLabel", - "chat.commentaryToggle" - ], - "generatedAt": "2026-06-30T23:31:03.766Z", + "fallbackKeys": [], + "generatedAt": "2026-07-01T01:07:32.299Z", "locale": "it", "model": "claude-opus-4-8", "provider": "anthropic", "sourceHash": "2efa88c26ff88470a76b18ae218dd9c325165f98587f359ed51dbfb51a7d8428", "totalKeys": 1418, - "translatedKeys": 1416, + "translatedKeys": 1418, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/it.tm.jsonl b/ui/src/i18n/.i18n/it.tm.jsonl index 292e3888f605..6b78a9f8d982 100644 --- a/ui/src/i18n/.i18n/it.tm.jsonl +++ b/ui/src/i18n/.i18n/it.tm.jsonl @@ -49,6 +49,7 @@ {"cache_key":"522aad631e9eb6e8b781667f04babfa67e7e872f356e3bf006fffcf0047219a0","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.detailNotePlaceholder","source_path":"ui/src/i18n/locales/it.ts","src_lang":"en","text":"Add a decision, blocker, or proof note...","text_hash":"0e40ea8371be2fcbd8379458b0da541ca0dce5dc86357dea64a4d8fac1c742dc","tgt_lang":"it","translated":"Aggiungi una decisione, un blocco o una nota di verifica...","updated_at":"2026-06-16T14:15:46.111Z"} {"cache_key":"527f7b455533c166625ce1dbeabc38f4490c46b0cf1bb0b128695c75c682c024","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.noSearchResults","source_path":"ui/src/i18n/locales/it.ts","src_lang":"en","text":"No matching files.","text_hash":"6ba2ede6c6019b640f63e7e48c5ee8238e701c6e539ce9abb5a7a9d9c71d8a73","tgt_lang":"it","translated":"Nessun file corrispondente.","updated_at":"2026-06-16T14:15:52.375Z"} {"cache_key":"5a9548f1084f3cf999e6ebe5a9f4936eef0a834882640344c5ae0ae724cdf8b3","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.viewReview","source_path":"ui/src/i18n/locales/it.ts","src_lang":"en","text":"Review","text_hash":"aff0766a5290e117b8433c351bae7b7b23bed682b2369bd822d88a647cc58512","tgt_lang":"it","translated":"Revisione","updated_at":"2026-06-17T14:19:01.168Z"} +{"cache_key":"5fd9092703d43b8ce7f885f30ed053516a60425f8f1384dd1c2ed5373e844ab8","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.commentaryToggle","source_path":"ui/src/i18n/locales/it.ts","src_lang":"en","text":"Keep commentary after the final answer","text_hash":"febdff0791d1ceeddb2b7596da8282f4dcfd5cff4cad11dea4c0a23944cd8ffe","tgt_lang":"it","translated":"Mantieni il commento dopo la risposta finale","updated_at":"2026-07-01T01:07:32.292Z"} {"cache_key":"5fddf1e0355e6c7ad4b84090846ae14761144c19b8ee135c3dd639e9e0652e8d","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.actions","source_path":"ui/src/i18n/locales/it.ts","src_lang":"en","text":"Workspace file actions","text_hash":"461817d921bc7672e95fe4a3b23f4ac2a4a20e35b3d6eef3f02e8f5ba4201050","tgt_lang":"it","translated":"Azioni sui file dell'area di lavoro","updated_at":"2026-06-16T14:15:52.376Z"} {"cache_key":"63c50429a6e4dfd077cc94e3719c1f6a81da1831499bbbdeb4dd873408136fb3","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.label","source_path":"ui/src/i18n/locales/it.ts","src_lang":"en","text":"Session workspace","text_hash":"c0e8ea0cf983d14e8ba3f8fc28976954d637fd50f807c69e9695715fd2384b78","tgt_lang":"it","translated":"Workspace sessione","updated_at":"2026-06-16T14:15:46.111Z"} {"cache_key":"64d7150aad7e267d8fd248a1c6be0d8e9c45f75f65e3b7942da5a4cb1992b587","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.collapse","source_path":"ui/src/i18n/locales/it.ts","src_lang":"en","text":"Collapse session workspace","text_hash":"b6174b882c37a98e640339d728652a0c1fa70d28ed53d8ccfb6e99363e86973b","tgt_lang":"it","translated":"Comprimi workspace sessione","updated_at":"2026-06-16T14:15:46.112Z"} @@ -70,6 +71,7 @@ {"cache_key":"81435fccbed10b32567dd2f9d1a96774ee201ea88d8244a9ac65c535575ef840","model":"gpt-5.5","provider":"openai","segment_id":"languages.hi","source_path":"ui/src/i18n/locales/it.ts","src_lang":"en","text":"हिन्दी (Hindi)","text_hash":"fab2abfce45382f3031c59477017700a8cb5dfaf8d15379dc24304809b97c7d5","tgt_lang":"it","translated":"हिन्दी (Hindi)","updated_at":"2026-06-26T21:43:33.749Z"} {"cache_key":"85132489f056e73c204760a92a7fcebba0bc13187acb73da9c1bd32db8c1a9d5","model":"gpt-5.5","provider":"openai","segment_id":"subtitles.skillWorkshop","source_path":"ui/src/i18n/locales/it.ts","src_lang":"en","text":"Review, refine, and apply proposals before they become live skills.","text_hash":"f907c72e9f18a205027257cd6fecdd52b03732227a17dcec0db038e11de3f8cc","tgt_lang":"it","translated":"Esamina, perfeziona e applica le proposte prima che diventino skill attive.","updated_at":"2026-05-31T21:48:28.496Z"} {"cache_key":"8ae364941577e40a34bbbb97bebe2005a623b0a7c2cdfd93a46439b5c1c42074","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.healthLabel","source_path":"ui/src/i18n/locales/it.ts","src_lang":"en","text":"Workboard health","text_hash":"85416c4a6d64e35611bdd9747b82815936c38b41d820796ba1fbfbb7539d906b","tgt_lang":"it","translated":"Stato workboard","updated_at":"2026-06-17T14:19:20.279Z"} +{"cache_key":"8bc992c95973ae45bbe50bba495103ec642179b547f3643035e12b23f4e19702","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.commentaryLabel","source_path":"ui/src/i18n/locales/it.ts","src_lang":"en","text":"Commentary","text_hash":"4a7a75ab79cde05b0b1baa8f7a704c991586ea44d7e7793e57178ef56f778ff8","tgt_lang":"it","translated":"Commento","updated_at":"2026-07-01T01:07:32.292Z"} {"cache_key":"8dcdaf0c7f0c5f02a4ecbf2271b20bc87e2d68c1bf337ef96731fd19ac95ce83","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.badgeHeartbeat","source_path":"ui/src/i18n/locales/it.ts","src_lang":"en","text":"heartbeat {age}","text_hash":"000637b3800ae069edbbe207cfad0a3f5037f06e9661ee89d70a1dfe6f404485","tgt_lang":"it","translated":"heartbeat {age}","updated_at":"2026-06-17T14:19:20.279Z"} {"cache_key":"8ea019ae4e670fe0a09e70a5fd660a9425bcfa3755f554e9a790af433ca13c97","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.autoRefresh60s","source_path":"ui/src/i18n/locales/it.ts","src_lang":"en","text":"60s","text_hash":"f79f071ab5b033ca8fb42c077f39708930d194b18f4608eb26ac1d9665a8836f","tgt_lang":"it","translated":"60s","updated_at":"2026-06-17T14:19:20.279Z"} {"cache_key":"919f2fc1e1cb9336975df8d8ff3d5a4d38ab70fea92c19ba3e6092513395f974","model":"claude-opus-4-8","provider":"anthropic","segment_id":"skillWorkshop.header.useCurrentChatAria","source_path":"ui/src/i18n/locales/it.ts","src_lang":"en","text":"Use current chat for revision requests","text_hash":"9c551a423ae74aedaaa90e4df9899dbdc02f846d6ee058bf2576a812e2c52119","tgt_lang":"it","translated":"Usa la chat corrente per le richieste di revisione","updated_at":"2026-06-16T14:15:39.914Z"} diff --git a/ui/src/i18n/locales/it.ts b/ui/src/i18n/locales/it.ts index 28d51c055e32..3804882ada7f 100644 --- a/ui/src/i18n/locales/it.ts +++ b/ui/src/i18n/locales/it.ts @@ -1336,8 +1336,8 @@ export const it: TranslationMap = { settings: "Impostazioni chat", thinkingToggle: "Attiva/disattiva output di pensiero/elaborazione dell'assistente", toolCallsToggle: "Attiva/disattiva chiamate agli strumenti e risultati strumenti", - commentaryToggle: "Keep commentary after the final answer", - commentaryLabel: "Commentary", + commentaryToggle: "Mantieni il commento dopo la risposta finale", + commentaryLabel: "Commento", autoScrollMode: "Modalità di scorrimento automatico", autoScrollAlways: "Sempre", autoScrollNearBottom: "Vicino al fondo", From 75bd44a94bfb096010273ae14935b9cd90ccc2d3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 01:07:51 +0000 Subject: [PATCH 10/29] chore(ui): refresh tr control ui locale --- ui/src/i18n/.i18n/tr.meta.json | 9 +++------ ui/src/i18n/.i18n/tr.tm.jsonl | 2 ++ ui/src/i18n/locales/tr.ts | 4 ++-- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/ui/src/i18n/.i18n/tr.meta.json b/ui/src/i18n/.i18n/tr.meta.json index 437112b31608..a4c8f1ec04b5 100644 --- a/ui/src/i18n/.i18n/tr.meta.json +++ b/ui/src/i18n/.i18n/tr.meta.json @@ -1,14 +1,11 @@ { - "fallbackKeys": [ - "chat.commentaryLabel", - "chat.commentaryToggle" - ], - "generatedAt": "2026-06-30T23:31:03.875Z", + "fallbackKeys": [], + "generatedAt": "2026-07-01T01:07:50.773Z", "locale": "tr", "model": "claude-opus-4-8", "provider": "anthropic", "sourceHash": "2efa88c26ff88470a76b18ae218dd9c325165f98587f359ed51dbfb51a7d8428", "totalKeys": 1418, - "translatedKeys": 1416, + "translatedKeys": 1418, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/tr.tm.jsonl b/ui/src/i18n/.i18n/tr.tm.jsonl index ff75cf03d424..c6c355be4f54 100644 --- a/ui/src/i18n/.i18n/tr.tm.jsonl +++ b/ui/src/i18n/.i18n/tr.tm.jsonl @@ -64,9 +64,11 @@ {"cache_key":"757043636a92ec1d820899cbc0054f425dbe072befadc709684f9a69c5f6baa8","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.agentFilterUnassignedHelp","source_path":"ui/src/i18n/locales/tr.ts","src_lang":"en","text":"Cards without an explicit agent.","text_hash":"f716a36252b33511df056fe7d1092be598eca17ea76bedc5d6d3532ec6b0ffea","tgt_lang":"tr","translated":"Açık bir agent olmayan kartlar.","updated_at":"2026-06-17T14:15:34.754Z"} {"cache_key":"763541d9e176624185a500f6c4ce240b509fbe83aef9299874f690ae69a1da52","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.detailProof","source_path":"ui/src/i18n/locales/tr.ts","src_lang":"en","text":"Proof","text_hash":"7fbb3ccf9640651f69af3626de6836fb302a0a088c7cd27721c367b8b530e502","tgt_lang":"tr","translated":"Kanıt","updated_at":"2026-06-16T14:15:38.462Z"} {"cache_key":"76c2f3075a3bed160f5718a7538e3c45e4688b06ba08a5b86de3d8f4ac2cd44a","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.autoRefresh60s","source_path":"ui/src/i18n/locales/tr.ts","src_lang":"en","text":"60s","text_hash":"f79f071ab5b033ca8fb42c077f39708930d194b18f4608eb26ac1d9665a8836f","tgt_lang":"tr","translated":"60sn","updated_at":"2026-06-17T14:15:40.711Z"} +{"cache_key":"78214610e84695d14d22c03150d37e66fb9d6d624e487e308401d172ff3d5c83","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.commentaryLabel","source_path":"ui/src/i18n/locales/tr.ts","src_lang":"en","text":"Commentary","text_hash":"4a7a75ab79cde05b0b1baa8f7a704c991586ea44d7e7793e57178ef56f778ff8","tgt_lang":"tr","translated":"Yorum","updated_at":"2026-07-01T01:07:50.766Z"} {"cache_key":"78f8981d3e6766c2ab71bdc380d753a3153c2cf3d535b52c08a7b7fdcbc63cb2","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.dependencyStatusMissing","source_path":"ui/src/i18n/locales/tr.ts","src_lang":"en","text":"Missing","text_hash":"6be36ca49ee85210c5d1ad9c377d90a9859c66d889110a2a5b0dccd390d12e20","tgt_lang":"tr","translated":"Eksik","updated_at":"2026-06-16T14:15:44.904Z"} {"cache_key":"7b948c18323d4be1867dda751c9821948113e65a7495abb860de1546073c7086","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.expand","source_path":"ui/src/i18n/locales/tr.ts","src_lang":"en","text":"Expand session workspace","text_hash":"ac1d210db40c5026879774849ad74a9e1247523192a795ac33965b3ee72691c2","tgt_lang":"tr","translated":"Oturum çalışma alanını genişlet","updated_at":"2026-06-16T14:15:44.904Z"} {"cache_key":"7e0f380e47bf1f155c1de859d51b0ae233ad0d38ccd077625c57179cd77b28c6","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.healthStale","source_path":"ui/src/i18n/locales/tr.ts","src_lang":"en","text":"stale","text_hash":"a03f2386ae06b21109577020844df367857b72c2fcce384c1896fed98a89c82b","tgt_lang":"tr","translated":"eski","updated_at":"2026-06-17T14:15:40.711Z"} +{"cache_key":"7e1baab014c7c62c1043319fad1e2ddd77698cf70d26b39959de210a9f26cce2","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.commentaryToggle","source_path":"ui/src/i18n/locales/tr.ts","src_lang":"en","text":"Keep commentary after the final answer","text_hash":"febdff0791d1ceeddb2b7596da8282f4dcfd5cff4cad11dea4c0a23944cd8ffe","tgt_lang":"tr","translated":"Nihai yanıttan sonra yorumu koru","updated_at":"2026-07-01T01:07:50.766Z"} {"cache_key":"830e86b81ff563467521c9eb63f3b28137906bceeef6d20d62b6c5c78efb4c07","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.missing","source_path":"ui/src/i18n/locales/tr.ts","src_lang":"en","text":"Missing","text_hash":"6be36ca49ee85210c5d1ad9c377d90a9859c66d889110a2a5b0dccd390d12e20","tgt_lang":"tr","translated":"Eksik","updated_at":"2026-06-16T14:15:55.679Z"} {"cache_key":"872feda832616c9bd77f7722ba79c2517c23e1bedcdc94ea7b7b04938f63c2fd","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.emptyFilteredTitle","source_path":"ui/src/i18n/locales/tr.ts","src_lang":"en","text":"No cards match this view","text_hash":"a272617e183ba1dff3f7f140d0851b64baf95f4827ff729d23dfeb05c2069875","tgt_lang":"tr","translated":"Bu görünümle eşleşen kart yok","updated_at":"2026-06-17T14:15:40.711Z"} {"cache_key":"8825b9eff1f02c8960e38e68b2fb8630108648fba80bbf3b3e2151d9ac60f855","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.browserCount","source_path":"ui/src/i18n/locales/tr.ts","src_lang":"en","text":"{count} shown","text_hash":"e57b4adfe868fd74a183650103d820176d4960bd0bdb677d9985db09f9752867","tgt_lang":"tr","translated":"{count} gösteriliyor","updated_at":"2026-06-16T14:15:55.679Z"} diff --git a/ui/src/i18n/locales/tr.ts b/ui/src/i18n/locales/tr.ts index 30e211ac1842..8d8195a385db 100644 --- a/ui/src/i18n/locales/tr.ts +++ b/ui/src/i18n/locales/tr.ts @@ -1335,8 +1335,8 @@ export const tr: TranslationMap = { settings: "Sohbet ayarları", thinkingToggle: "Asistanın düşünme/çalışma çıktısını aç/kapat", toolCallsToggle: "Araç çağrılarını ve araç sonuçlarını aç/kapat", - commentaryToggle: "Keep commentary after the final answer", - commentaryLabel: "Commentary", + commentaryToggle: "Nihai yanıttan sonra yorumu koru", + commentaryLabel: "Yorum", autoScrollMode: "Otomatik kaydırma modu", autoScrollAlways: "Her zaman", autoScrollNearBottom: "Alta yakın", From c1aeaf9287ec25428ed1e673d869e193e930ba6b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 01:07:59 +0000 Subject: [PATCH 11/29] chore(ui): refresh id control ui locale --- ui/src/i18n/.i18n/id.meta.json | 9 +++------ ui/src/i18n/.i18n/id.tm.jsonl | 2 ++ ui/src/i18n/locales/id.ts | 4 ++-- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/ui/src/i18n/.i18n/id.meta.json b/ui/src/i18n/.i18n/id.meta.json index 82e8f959d10e..b799d0b90273 100644 --- a/ui/src/i18n/.i18n/id.meta.json +++ b/ui/src/i18n/.i18n/id.meta.json @@ -1,14 +1,11 @@ { - "fallbackKeys": [ - "chat.commentaryLabel", - "chat.commentaryToggle" - ], - "generatedAt": "2026-06-30T23:31:04.111Z", + "fallbackKeys": [], + "generatedAt": "2026-07-01T01:07:58.632Z", "locale": "id", "model": "claude-opus-4-8", "provider": "anthropic", "sourceHash": "2efa88c26ff88470a76b18ae218dd9c325165f98587f359ed51dbfb51a7d8428", "totalKeys": 1418, - "translatedKeys": 1416, + "translatedKeys": 1418, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/id.tm.jsonl b/ui/src/i18n/.i18n/id.tm.jsonl index 8e5092bd936b..efad2a863be0 100644 --- a/ui/src/i18n/.i18n/id.tm.jsonl +++ b/ui/src/i18n/.i18n/id.tm.jsonl @@ -11,6 +11,7 @@ {"cache_key":"16ac4c899924f98a6a2920c90997debeac224231488a7e8d10888054b988216d","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.detailWorkerProtocol","source_path":"ui/src/i18n/locales/id.ts","src_lang":"en","text":"Worker protocol","text_hash":"e445d823cfa48c4e8fa1d8854771e9939955e772428be6d7957deec0f7968764","tgt_lang":"id","translated":"Protokol worker","updated_at":"2026-06-16T14:16:44.079Z"} {"cache_key":"1ca16289a1c9ee2d9bc5ede8a75396e8d9356dfdc6d0984fb331101587d4f422","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.lastRefreshed","source_path":"ui/src/i18n/locales/id.ts","src_lang":"en","text":"Updated {time}","text_hash":"2f87419441e6111b4d62893d3c4ef5ddeb2c8e1af82fabab6132856faf77f907","tgt_lang":"id","translated":"Diperbarui {time}","updated_at":"2026-06-17T14:16:03.086Z"} {"cache_key":"1e575f21e4b5bad1a0669038c53673ae26721b967599fd634483562dc92a5637","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.viewAll","source_path":"ui/src/i18n/locales/id.ts","src_lang":"en","text":"All cards","text_hash":"2306120917506b5998ec702f3661068b102dac538ba4c9e4634d65fe33eea98a","tgt_lang":"id","translated":"Semua kartu","updated_at":"2026-06-17T14:15:58.283Z"} +{"cache_key":"2214a5eb361fa9f77286505dddbb064637bcc50091600ea9f47a0c146b15e737","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.commentaryLabel","source_path":"ui/src/i18n/locales/id.ts","src_lang":"en","text":"Commentary","text_hash":"4a7a75ab79cde05b0b1baa8f7a704c991586ea44d7e7793e57178ef56f778ff8","tgt_lang":"id","translated":"Komentar","updated_at":"2026-07-01T01:07:58.622Z"} {"cache_key":"227a95d5e4f762423adc7b47def93a1c5f8d0e45ee05c32c7ab429a3ba398a93","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.detailDiagnostics","source_path":"ui/src/i18n/locales/id.ts","src_lang":"en","text":"Diagnostics","text_hash":"268f14bbfe119c1e92150583af960a086d7db9619a097f8aa72ff6779842f610","tgt_lang":"id","translated":"Diagnostik","updated_at":"2026-06-16T14:16:44.079Z"} {"cache_key":"23db780eaf067ba537b4fec8e0ee4d135d25682e28dd61fac94aa127848524b8","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.dependencyStatusMissing","source_path":"ui/src/i18n/locales/id.ts","src_lang":"en","text":"Missing","text_hash":"6be36ca49ee85210c5d1ad9c377d90a9859c66d889110a2a5b0dccd390d12e20","tgt_lang":"id","translated":"Hilang","updated_at":"2026-06-16T14:16:50.406Z"} {"cache_key":"243f9918d0f1e3d9ee9c3c0e560901be19c81e478b17cb0f7ea98b6bf9a9fc34","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.detailOperatorNotes","source_path":"ui/src/i18n/locales/id.ts","src_lang":"en","text":"Operator notes","text_hash":"7d2a121620cebfb9c4f6c0f82b693b75d65a4210b8232d77ef87e45fce334347","tgt_lang":"id","translated":"Catatan operator","updated_at":"2026-06-16T14:16:44.079Z"} @@ -103,6 +104,7 @@ {"cache_key":"da6a4973169844d42ff83c5ef81720c02c4e49e1f8af9d4739ffabb2792af076","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.emptyFilteredHint","source_path":"ui/src/i18n/locales/id.ts","src_lang":"en","text":"Change the view, search, priority, agent, or archive filter.","text_hash":"049dfae940263ace9707334af06b298c1223c38a449b1cec5a712553badebbd0","tgt_lang":"id","translated":"Ubah filter tampilan, pencarian, prioritas, agen, atau arsip.","updated_at":"2026-06-17T14:16:03.086Z"} {"cache_key":"db0ab96e8ec3965f1ac322a9152776dd03849c5c65facbc7d3c4db16698b4a03","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.changed","source_path":"ui/src/i18n/locales/id.ts","src_lang":"en","text":"Changed","text_hash":"2a6141e43be0c2125e3b5d9f74b4ff1261a0b320ff927c83d4d9b1b65585bad7","tgt_lang":"id","translated":"Diubah","updated_at":"2026-06-16T14:16:57.267Z"} {"cache_key":"dbd97a91bf0cda6d66e22e140333d00e402ba31801432e7f1287141a5be8b3ae","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.session","source_path":"ui/src/i18n/locales/id.ts","src_lang":"en","text":"Session","text_hash":"6959b4159575d8dd76d9f3bbe2c6437904f861e7860c35abd18deffb1c3425a0","tgt_lang":"id","translated":"Sesi","updated_at":"2026-06-16T14:16:57.267Z"} +{"cache_key":"df66b8402857730723182ecd1fbb8c77ca79e4c7539e4269eb19ca3f7f4258f1","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.commentaryToggle","source_path":"ui/src/i18n/locales/id.ts","src_lang":"en","text":"Keep commentary after the final answer","text_hash":"febdff0791d1ceeddb2b7596da8282f4dcfd5cff4cad11dea4c0a23944cd8ffe","tgt_lang":"id","translated":"Simpan komentar setelah jawaban akhir","updated_at":"2026-07-01T01:07:58.622Z"} {"cache_key":"df7bab00f1962121b0163be590b8067de30660a13ac55ca455b2678170f234d0","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.parentFolder","source_path":"ui/src/i18n/locales/id.ts","src_lang":"en","text":"Parent folder","text_hash":"158f5a01ef8cfb1e6d91f8c321dd3a63f5e457f9650eecd662857701762bd31d","tgt_lang":"id","translated":"Folder induk","updated_at":"2026-06-16T14:16:57.267Z"} {"cache_key":"e0737763eb51a1941b8cccb78d07d18432f7685c3e7f35b454f4ad894fa2476f","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.dependenciesBlocked","source_path":"ui/src/i18n/locales/id.ts","src_lang":"en","text":"{count} blocked","text_hash":"fb39869b0fb3b8933126014e5c3739d7d67a620b8369781ca27e7395c595bde8","tgt_lang":"id","translated":"{count} terblokir","updated_at":"2026-06-16T14:16:50.406Z"} {"cache_key":"e2c8af96e934057f2b7fc227e2eced830ee92d6aab1f84a51b93667e24097977","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.refreshError","source_path":"ui/src/i18n/locales/id.ts","src_lang":"en","text":"Refresh failed","text_hash":"8fa7e6d90bef4e5cb735233347bf6a71b5b30d96e7c1a50b73f10cb441b275c2","tgt_lang":"id","translated":"Penyegaran gagal","updated_at":"2026-06-17T14:16:03.086Z"} diff --git a/ui/src/i18n/locales/id.ts b/ui/src/i18n/locales/id.ts index 5ae1892bee1a..d3e7699c7ac4 100644 --- a/ui/src/i18n/locales/id.ts +++ b/ui/src/i18n/locales/id.ts @@ -1329,8 +1329,8 @@ export const id: TranslationMap = { settings: "Pengaturan chat", thinkingToggle: "Alihkan output berpikir/bekerja asisten", toolCallsToggle: "Alihkan panggilan alat dan hasil alat", - commentaryToggle: "Keep commentary after the final answer", - commentaryLabel: "Commentary", + commentaryToggle: "Simpan komentar setelah jawaban akhir", + commentaryLabel: "Komentar", autoScrollMode: "Mode gulir otomatis", autoScrollAlways: "Selalu", autoScrollNearBottom: "Dekat bagian bawah", From 49edc0a8af2f06cca21cd6df2abd81a7d99a574c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 01:08:00 +0000 Subject: [PATCH 12/29] chore(ui): refresh uk control ui locale --- ui/src/i18n/.i18n/uk.meta.json | 9 +++------ ui/src/i18n/.i18n/uk.tm.jsonl | 2 ++ ui/src/i18n/locales/uk.ts | 4 ++-- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/ui/src/i18n/.i18n/uk.meta.json b/ui/src/i18n/.i18n/uk.meta.json index 94400c024067..0f74a24ba823 100644 --- a/ui/src/i18n/.i18n/uk.meta.json +++ b/ui/src/i18n/.i18n/uk.meta.json @@ -1,14 +1,11 @@ { - "fallbackKeys": [ - "chat.commentaryLabel", - "chat.commentaryToggle" - ], - "generatedAt": "2026-06-30T23:31:04.011Z", + "fallbackKeys": [], + "generatedAt": "2026-07-01T01:07:59.462Z", "locale": "uk", "model": "claude-opus-4-8", "provider": "anthropic", "sourceHash": "2efa88c26ff88470a76b18ae218dd9c325165f98587f359ed51dbfb51a7d8428", "totalKeys": 1418, - "translatedKeys": 1416, + "translatedKeys": 1418, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/uk.tm.jsonl b/ui/src/i18n/.i18n/uk.tm.jsonl index 9a7f9ea26984..1e86c95f11f5 100644 --- a/ui/src/i18n/.i18n/uk.tm.jsonl +++ b/ui/src/i18n/.i18n/uk.tm.jsonl @@ -73,6 +73,7 @@ {"cache_key":"86729637e26dff5d5b24f944b02fd2cc77f22dbcdadf6b124ef84202e39f72ad","model":"claude-opus-4-8","provider":"anthropic","segment_id":"cron.jobDetail.cwd","source_path":"ui/src/i18n/locales/uk.ts","src_lang":"en","text":"CWD","text_hash":"0217f1cb7725737f15a6710df3bcfa3bc10a239f0f7801ec3d7168e675f5ebd6","tgt_lang":"uk","translated":"CWD","updated_at":"2026-06-16T14:16:03.477Z"} {"cache_key":"8aee8ce3ea0eee44cf73ab98985116ed60a07d9737e132e7b5b0cda3b6f68b58","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.read","source_path":"ui/src/i18n/locales/uk.ts","src_lang":"en","text":"Read","text_hash":"9b9a8d05a7ec353bda84f9c1bb3178c299de3001b5e970508ddc889c487f92ca","tgt_lang":"uk","translated":"Прочитано","updated_at":"2026-06-16T14:16:00.845Z"} {"cache_key":"8df9e9c243a5deae1a316c4b873b9d5475fc58869de9095af7752e831b8b72da","model":"gpt-5.5","provider":"openai","segment_id":"workboard.eventOrchestration","source_path":"ui/src/i18n/locales/uk.ts","src_lang":"en","text":"Orchestration","text_hash":"ed4fdd1721677737cffb2862fe34d5b63901c7cc76b8c67c51e92a467b31a5e7","tgt_lang":"uk","translated":"Оркестрація","updated_at":"2026-05-30T15:38:37.442Z"} +{"cache_key":"90fd997d151da52e48e0c7557a34336ccf9e357592c8cab9933f781172590f47","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.commentaryLabel","source_path":"ui/src/i18n/locales/uk.ts","src_lang":"en","text":"Commentary","text_hash":"4a7a75ab79cde05b0b1baa8f7a704c991586ea44d7e7793e57178ef56f778ff8","tgt_lang":"uk","translated":"Коментар","updated_at":"2026-07-01T01:07:59.455Z"} {"cache_key":"993354ed03232adab1033ad5392b8d53265d914356478f578eddfeebb0415391","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.detailProof","source_path":"ui/src/i18n/locales/uk.ts","src_lang":"en","text":"Proof","text_hash":"7fbb3ccf9640651f69af3626de6836fb302a0a088c7cd27721c367b8b530e502","tgt_lang":"uk","translated":"Підтвердження","updated_at":"2026-06-16T14:15:40.204Z"} {"cache_key":"a30cdf03bb03e6ba41ceceb31762ab6ffed0a93212ef15dd8a262ce60f06e7bd","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.autoRefresh60s","source_path":"ui/src/i18n/locales/uk.ts","src_lang":"en","text":"60s","text_hash":"f79f071ab5b033ca8fb42c077f39708930d194b18f4608eb26ac1d9665a8836f","tgt_lang":"uk","translated":"60 с","updated_at":"2026-06-17T14:15:34.049Z"} {"cache_key":"a791027ff4c63da58580abdd6865d2955a927dc8cd3333bf944a1e04e4c88139","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.layoutCompact","source_path":"ui/src/i18n/locales/uk.ts","src_lang":"en","text":"Compact card density","text_hash":"2f532993d5a6ccda4d758c7ecdda8bebaa857218045a3d4a011fec73d9728785","tgt_lang":"uk","translated":"Компактна щільність карток","updated_at":"2026-06-17T14:15:28.579Z"} @@ -87,6 +88,7 @@ {"cache_key":"b4811e5fa17d0761d1e3db804b1d5e926c86a048d16d24de67a19aa30a5a0b8f","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.detailWorkerProtocol","source_path":"ui/src/i18n/locales/uk.ts","src_lang":"en","text":"Worker protocol","text_hash":"e445d823cfa48c4e8fa1d8854771e9939955e772428be6d7957deec0f7968764","tgt_lang":"uk","translated":"Протокол воркера","updated_at":"2026-06-16T14:15:40.204Z"} {"cache_key":"b548bf989175808afced3647ca6e1dd4ae98d7a73f9d74cacf7d34d81cbb6d3f","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.dependencies","source_path":"ui/src/i18n/locales/uk.ts","src_lang":"en","text":"Dependencies","text_hash":"2e41b118eb209c139f2bcbf690486f6e1509ab978aa96feb053877a70a1a5a09","tgt_lang":"uk","translated":"Залежності","updated_at":"2026-06-16T14:15:46.702Z"} {"cache_key":"b6095799115b413d4a9177fa960ba36a7fefda5bbb3688977edebe009ab8cb43","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.healthFailedAttempts","source_path":"ui/src/i18n/locales/uk.ts","src_lang":"en","text":"failed attempts","text_hash":"fd9023af0795825a458100ddbe894a7a8f603324a2b7ad2305d4c9d2334cbd26","tgt_lang":"uk","translated":"невдалі спроби","updated_at":"2026-06-17T14:15:34.049Z"} +{"cache_key":"b74912e5d6ed30c3cec1993ab7420b469f53d63415e33574719987c10a6c3948","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.commentaryToggle","source_path":"ui/src/i18n/locales/uk.ts","src_lang":"en","text":"Keep commentary after the final answer","text_hash":"febdff0791d1ceeddb2b7596da8282f4dcfd5cff4cad11dea4c0a23944cd8ffe","tgt_lang":"uk","translated":"Зберігати коментар після остаточної відповіді","updated_at":"2026-07-01T01:07:59.454Z"} {"cache_key":"bab660bc64e7a08a01caff0fe11083689684c8dffed0213b8e79b04ce29ca124","model":"gpt-5.5","provider":"openai","segment_id":"workboard.eventProtocolViolation","source_path":"ui/src/i18n/locales/uk.ts","src_lang":"en","text":"Protocol violation","text_hash":"367bb2052963f7d75beb672d3ca0430d7d49ac48a2759d578c7df933178fe564","tgt_lang":"uk","translated":"Порушення протоколу","updated_at":"2026-05-30T15:38:37.442Z"} {"cache_key":"c436a35b396e8435e1c7c549670fbccdbe87f59cbc74b6d56a41f4f7fbecca3d","model":"gpt-5.5","provider":"openai","segment_id":"workboard.badgeWorkerProtocol","source_path":"ui/src/i18n/locales/uk.ts","src_lang":"en","text":"worker {state}","text_hash":"f16b9e04d42182b421ce4f4e982b2ef75fab9bd581bdc8b87e62899ba28de11c","tgt_lang":"uk","translated":"воркер {state}","updated_at":"2026-05-30T15:38:37.442Z"} {"cache_key":"c7f724db3e12f5bd45d1c3442c296d13af320b1fdb7cbb18ce5b46e75ced267e","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.collapse","source_path":"ui/src/i18n/locales/uk.ts","src_lang":"en","text":"Collapse session workspace","text_hash":"b6174b882c37a98e640339d728652a0c1fa70d28ed53d8ccfb6e99363e86973b","tgt_lang":"uk","translated":"Згорнути робочу область сесії","updated_at":"2026-06-16T14:15:46.702Z"} diff --git a/ui/src/i18n/locales/uk.ts b/ui/src/i18n/locales/uk.ts index c07a0146cb17..de8b431f2bc9 100644 --- a/ui/src/i18n/locales/uk.ts +++ b/ui/src/i18n/locales/uk.ts @@ -1333,8 +1333,8 @@ export const uk: TranslationMap = { settings: "Налаштування чату", thinkingToggle: "Перемкнути показ мислення/роботи асистента", toolCallsToggle: "Перемкнути виклики інструментів і результати інструментів", - commentaryToggle: "Keep commentary after the final answer", - commentaryLabel: "Commentary", + commentaryToggle: "Зберігати коментар після остаточної відповіді", + commentaryLabel: "Коментар", autoScrollMode: "Режим автопрокручування", autoScrollAlways: "Завжди", autoScrollNearBottom: "Біля низу", From cb69ce7ba84f7252acd3b87653a0a91b7442f41d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 01:08:14 +0000 Subject: [PATCH 13/29] chore(ui): refresh pl control ui locale --- ui/src/i18n/.i18n/pl.meta.json | 9 +++------ ui/src/i18n/.i18n/pl.tm.jsonl | 2 ++ ui/src/i18n/locales/pl.ts | 4 ++-- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/ui/src/i18n/.i18n/pl.meta.json b/ui/src/i18n/.i18n/pl.meta.json index 98296e17e177..541361da0852 100644 --- a/ui/src/i18n/.i18n/pl.meta.json +++ b/ui/src/i18n/.i18n/pl.meta.json @@ -1,14 +1,11 @@ { - "fallbackKeys": [ - "chat.commentaryLabel", - "chat.commentaryToggle" - ], - "generatedAt": "2026-06-30T23:31:04.214Z", + "fallbackKeys": [], + "generatedAt": "2026-07-01T01:08:13.118Z", "locale": "pl", "model": "claude-opus-4-8", "provider": "anthropic", "sourceHash": "2efa88c26ff88470a76b18ae218dd9c325165f98587f359ed51dbfb51a7d8428", "totalKeys": 1418, - "translatedKeys": 1416, + "translatedKeys": 1418, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/pl.tm.jsonl b/ui/src/i18n/.i18n/pl.tm.jsonl index e6f654425c6e..252bb60991fc 100644 --- a/ui/src/i18n/.i18n/pl.tm.jsonl +++ b/ui/src/i18n/.i18n/pl.tm.jsonl @@ -75,6 +75,7 @@ {"cache_key":"8df6be9bca71f11691d6ef60fe58f682803b121ef655f219d9a2b83209bf4019","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.detailWorkerProtocol","source_path":"ui/src/i18n/locales/pl.ts","src_lang":"en","text":"Worker protocol","text_hash":"e445d823cfa48c4e8fa1d8854771e9939955e772428be6d7957deec0f7968764","tgt_lang":"pl","translated":"Protokół workera","updated_at":"2026-06-16T14:17:07.423Z"} {"cache_key":"97d8cd4b3d898ec257fd4f49f241a337a29dd6b1fe77ba5543d094665ed7f793","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.artifactCount","source_path":"ui/src/i18n/locales/pl.ts","src_lang":"en","text":"{count} artifacts","text_hash":"022b6b55a10f1864b7aff7c307e49ab37e11e6999111fb87349f040d100abba3","tgt_lang":"pl","translated":"{count} artefaktów","updated_at":"2026-06-16T14:17:21.494Z"} {"cache_key":"97ed43a316faf65a1ce7bf5b1fe783afc6d2adc799adf02ab1b7df1b7ee5fdea","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.refresh","source_path":"ui/src/i18n/locales/pl.ts","src_lang":"en","text":"Refresh session workspace","text_hash":"c7a97b20a3a3ce348239c4893c99f1902d44877567cb32f752c30cdfbc9a2468","tgt_lang":"pl","translated":"Odśwież obszar roboczy sesji","updated_at":"2026-06-16T14:17:14.640Z"} +{"cache_key":"98ef6246f39d01701b46dcc18bf5fac14155e8f5caa6882f5a0562a00f6041e5","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.commentaryLabel","source_path":"ui/src/i18n/locales/pl.ts","src_lang":"en","text":"Commentary","text_hash":"4a7a75ab79cde05b0b1baa8f7a704c991586ea44d7e7793e57178ef56f778ff8","tgt_lang":"pl","translated":"Komentarz","updated_at":"2026-07-01T01:08:13.110Z"} {"cache_key":"9ae1ac32dd02417689af0ebb9a2336f3ac904cfb2e2fd483a2fcf9a202f0843d","model":"claude-opus-4-8","provider":"anthropic","segment_id":"skillWorkshop.header.useCurrentChatAria","source_path":"ui/src/i18n/locales/pl.ts","src_lang":"en","text":"Use current chat for revision requests","text_hash":"9c551a423ae74aedaaa90e4df9899dbdc02f846d6ee058bf2576a812e2c52119","tgt_lang":"pl","translated":"Użyj bieżącego czatu do próśb o poprawki","updated_at":"2026-06-16T14:17:07.423Z"} {"cache_key":"9e997e50b54a117996a600e18094f063a9211dcf931c0b28158d5168217944bd","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.detailDiagnostics","source_path":"ui/src/i18n/locales/pl.ts","src_lang":"en","text":"Diagnostics","text_hash":"268f14bbfe119c1e92150583af960a086d7db9619a097f8aa72ff6779842f610","tgt_lang":"pl","translated":"Diagnostyka","updated_at":"2026-06-16T14:17:07.423Z"} {"cache_key":"9ea7773002721e96653d413ea9c842df6e3fe806ec783b24bf4684c1a9a5c867","model":"claude-opus-4-8","provider":"anthropic","segment_id":"skillWorkshop.header.useCurrentChat","source_path":"ui/src/i18n/locales/pl.ts","src_lang":"en","text":"Use current chat","text_hash":"fbc1ffd63daa506e927c7a85f6e43acd11e0b8c9f52a3951fc782b236ce9a787","tgt_lang":"pl","translated":"Użyj bieżącego czatu","updated_at":"2026-06-16T14:17:07.422Z"} @@ -87,6 +88,7 @@ {"cache_key":"b107e5a8d94b6767e463fcf0af7cc9afffda3ed17180bd90fb5f3c22cf30f00a","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.autoRefresh60s","source_path":"ui/src/i18n/locales/pl.ts","src_lang":"en","text":"60s","text_hash":"f79f071ab5b033ca8fb42c077f39708930d194b18f4608eb26ac1d9665a8836f","tgt_lang":"pl","translated":"60 s","updated_at":"2026-06-17T14:16:37.628Z"} {"cache_key":"b25fd3633ffb725cc8ec92f4e670aefac62535b06a0a474a8a060923d835efa0","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.readCount","source_path":"ui/src/i18n/locales/pl.ts","src_lang":"en","text":"{count} read","text_hash":"b3c6c64f1153fb7b2672d2894f532d3f7adea1dd1c473363587fc520be35998e","tgt_lang":"pl","translated":"{count} odczytanych","updated_at":"2026-06-16T14:17:21.494Z"} {"cache_key":"b403d30e80f32d63916b966022d4d2fa4802a6b1243829638409f35bfe6865b8","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.viewMissingProof","source_path":"ui/src/i18n/locales/pl.ts","src_lang":"en","text":"Missing proof","text_hash":"b46debe888e32eec183dc5936c79d22ea43bec580c410c2b3c1aa24aaa75d677","tgt_lang":"pl","translated":"Brak dowodu","updated_at":"2026-06-17T14:16:32.528Z"} +{"cache_key":"b4a91512a8e914bbd1d746e1c223b9994e3a99b455c5d92df7f835049d2048be","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.commentaryToggle","source_path":"ui/src/i18n/locales/pl.ts","src_lang":"en","text":"Keep commentary after the final answer","text_hash":"febdff0791d1ceeddb2b7596da8282f4dcfd5cff4cad11dea4c0a23944cd8ffe","tgt_lang":"pl","translated":"Zachowaj komentarz po ostatecznej odpowiedzi","updated_at":"2026-07-01T01:08:13.110Z"} {"cache_key":"b508e049bff265f8e7b3b615e19ee6e41a7ba937f7991266a76dd1f030aba345","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.dependenciesReadyTitle","source_path":"ui/src/i18n/locales/pl.ts","src_lang":"en","text":"{count} dependencies are done.","text_hash":"559fe92cd5fe39b4f511a146fc7ce6b51e7f528e1d388bbfde1d85dddb60604d","tgt_lang":"pl","translated":"Ukończono {count} zależności.","updated_at":"2026-06-16T14:17:14.640Z"} {"cache_key":"b52a204ee3e27e8500f65fd0aa83a099843d6ee77d623e6e0de418fad1410a50","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.dependenciesBlockedTitle","source_path":"ui/src/i18n/locales/pl.ts","src_lang":"en","text":"Waiting on dependencies: {parents}.","text_hash":"50fb8f9b1326b69bd67d25583ddb4f70b9d75ae6e3ff8a9056a9361daa4b7d8b","tgt_lang":"pl","translated":"Oczekiwanie na zależności: {parents}.","updated_at":"2026-06-16T14:17:14.640Z"} {"cache_key":"b5617116eb5a8dd0f87c6cc593285d53b5b7dbd511273001b51ff19c33d71989","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.viewReady","source_path":"ui/src/i18n/locales/pl.ts","src_lang":"en","text":"Ready","text_hash":"5fa7aac5375c5815787fba3f49559f9b45b14023147ce0652803387974144e5f","tgt_lang":"pl","translated":"Gotowe","updated_at":"2026-06-17T14:16:32.528Z"} diff --git a/ui/src/i18n/locales/pl.ts b/ui/src/i18n/locales/pl.ts index 0d1cb870b928..9f1bfe45e7d3 100644 --- a/ui/src/i18n/locales/pl.ts +++ b/ui/src/i18n/locales/pl.ts @@ -1334,8 +1334,8 @@ export const pl: TranslationMap = { settings: "Ustawienia czatu", thinkingToggle: "Przełącz wyświetlanie myślenia/pracy asystenta", toolCallsToggle: "Przełącz wyświetlanie wywołań narzędzi i wyników narzędzi", - commentaryToggle: "Keep commentary after the final answer", - commentaryLabel: "Commentary", + commentaryToggle: "Zachowaj komentarz po ostatecznej odpowiedzi", + commentaryLabel: "Komentarz", autoScrollMode: "Tryb automatycznego przewijania", autoScrollAlways: "Zawsze", autoScrollNearBottom: "Blisko dołu", From bb0af61d20277cf91205e6fe561dc7cccfc8e969 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 01:08:35 +0000 Subject: [PATCH 14/29] chore(ui): refresh th control ui locale --- ui/src/i18n/.i18n/th.meta.json | 9 +++------ ui/src/i18n/.i18n/th.tm.jsonl | 2 ++ ui/src/i18n/locales/th.ts | 4 ++-- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/ui/src/i18n/.i18n/th.meta.json b/ui/src/i18n/.i18n/th.meta.json index 73e249c013b6..0e412542054f 100644 --- a/ui/src/i18n/.i18n/th.meta.json +++ b/ui/src/i18n/.i18n/th.meta.json @@ -1,14 +1,11 @@ { - "fallbackKeys": [ - "chat.commentaryLabel", - "chat.commentaryToggle" - ], - "generatedAt": "2026-06-30T23:31:04.319Z", + "fallbackKeys": [], + "generatedAt": "2026-07-01T01:08:34.116Z", "locale": "th", "model": "claude-opus-4-8", "provider": "anthropic", "sourceHash": "2efa88c26ff88470a76b18ae218dd9c325165f98587f359ed51dbfb51a7d8428", "totalKeys": 1418, - "translatedKeys": 1416, + "translatedKeys": 1418, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/th.tm.jsonl b/ui/src/i18n/.i18n/th.tm.jsonl index 6e1372f4738b..fed95412cfe7 100644 --- a/ui/src/i18n/.i18n/th.tm.jsonl +++ b/ui/src/i18n/.i18n/th.tm.jsonl @@ -26,11 +26,13 @@ {"cache_key":"3ab6d6111f2f5922d79ed2e0d5555dbecb4ff8c324e632e3329b44a9b77169cc","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.viewAll","source_path":"ui/src/i18n/locales/th.ts","src_lang":"en","text":"All cards","text_hash":"2306120917506b5998ec702f3661068b102dac538ba4c9e4634d65fe33eea98a","tgt_lang":"th","translated":"การ์ดทั้งหมด","updated_at":"2026-06-17T14:16:47.316Z"} {"cache_key":"3c5d07d156661386692b3d8c2a71ac4fe00174533cc8de778fea9eb06e18aa50","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.viewDetails","source_path":"ui/src/i18n/locales/th.ts","src_lang":"en","text":"View details","text_hash":"d1bf045bb524dae5b02c471c230958bcd1bf232d7a49367b1cdf977855a06b41","tgt_lang":"th","translated":"ดูรายละเอียด","updated_at":"2026-06-16T14:17:16.747Z"} {"cache_key":"3d63c31b922de8f93f71559de6ed6c4ffea1f8b90a7d267c2e8540271cffbbd9","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.read","source_path":"ui/src/i18n/locales/th.ts","src_lang":"en","text":"Read","text_hash":"9b9a8d05a7ec353bda84f9c1bb3178c299de3001b5e970508ddc889c487f92ca","tgt_lang":"th","translated":"อ่านแล้ว","updated_at":"2026-06-16T14:17:36.173Z"} +{"cache_key":"3ec7e5c1ffad1f6ff867594cf51c3872c052bd964c4a683c06217ff448b56ab0","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.commentaryToggle","source_path":"ui/src/i18n/locales/th.ts","src_lang":"en","text":"Keep commentary after the final answer","text_hash":"febdff0791d1ceeddb2b7596da8282f4dcfd5cff4cad11dea4c0a23944cd8ffe","tgt_lang":"th","translated":"เก็บคำอธิบายหลังคำตอบสุดท้าย","updated_at":"2026-07-01T01:08:34.109Z"} {"cache_key":"3f63d0db1eeff1295fb4ac845945d539bdf97febd344e8b2c7be96e78a1c3191","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.layoutCompact","source_path":"ui/src/i18n/locales/th.ts","src_lang":"en","text":"Compact card density","text_hash":"2f532993d5a6ccda4d758c7ecdda8bebaa857218045a3d4a011fec73d9728785","tgt_lang":"th","translated":"ความหนาแน่นการ์ดแบบกระชับ","updated_at":"2026-06-17T14:16:47.317Z"} {"cache_key":"409e7a32773ca4cfaad6040b79e08f395724751a56fca118fa183822811287ce","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.detailAddNote","source_path":"ui/src/i18n/locales/th.ts","src_lang":"en","text":"Add note","text_hash":"63565c0485fec4f743719849734553a5d7947f5962ec9e831e3bce131b3c47fb","tgt_lang":"th","translated":"เพิ่มบันทึก","updated_at":"2026-06-16T14:17:26.503Z"} {"cache_key":"42589e427cf6d03dffac4a1bc56074e4134e27c2261cdc2df9dec24220b90b63","model":"gpt-5.5","provider":"openai","segment_id":"tabs.mcp","source_path":"ui/src/i18n/locales/th.ts","src_lang":"en","text":"MCP","text_hash":"53f13ae99ed53bd346eb8e1c8cefb7ef8260683b50401caf101360967ea052aa","tgt_lang":"th","translated":"MCP","updated_at":"2026-05-31T05:36:53.074Z"} {"cache_key":"458d5f79ca3c3a42dc4f3ff021a62bf5596e20dbfd565d92d5ff44c0ace06b97","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.missing","source_path":"ui/src/i18n/locales/th.ts","src_lang":"en","text":"Missing","text_hash":"6be36ca49ee85210c5d1ad9c377d90a9859c66d889110a2a5b0dccd390d12e20","tgt_lang":"th","translated":"ขาดหายไป","updated_at":"2026-06-16T14:17:36.173Z"} {"cache_key":"49fd0ac92802175ac5684421b46287b291f54f5a2d4d2f36681b92a8ce207041","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.autoRefresh5s","source_path":"ui/src/i18n/locales/th.ts","src_lang":"en","text":"5s","text_hash":"93e3d8c5b10657d2884f177488b689aadf82a83f962237cb602b3314386ab3b7","tgt_lang":"th","translated":"5 วินาที","updated_at":"2026-06-17T14:16:55.187Z"} +{"cache_key":"509defbafdfd12227921ae61d30ab8c09a8ba87ff32413c32bf6c1e098458449","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.commentaryLabel","source_path":"ui/src/i18n/locales/th.ts","src_lang":"en","text":"Commentary","text_hash":"4a7a75ab79cde05b0b1baa8f7a704c991586ea44d7e7793e57178ef56f778ff8","tgt_lang":"th","translated":"คำอธิบาย","updated_at":"2026-07-01T01:08:34.109Z"} {"cache_key":"53a1ada7ff279b0d93db641f2980a4de3dccfa92752cbfa74c1dd4943fdf165b","model":"gpt-5.5","provider":"openai","segment_id":"workboard.badgeAttachments","source_path":"ui/src/i18n/locales/th.ts","src_lang":"en","text":"{count} attachments","text_hash":"7bb1847693bc91e6e4624d996a96840396a71052786ab143ccb47fbdaa77cf41","tgt_lang":"th","translated":"ไฟล์แนบ {count} รายการ","updated_at":"2026-05-30T15:38:44.763Z"} {"cache_key":"5a327590fef3c90c616f3dfaf750dbbb6c3587cb46806d6ec704d03ffdde28b7","model":"claude-opus-4-8","provider":"anthropic","segment_id":"skillWorkshop.header.useCurrentChatAria","source_path":"ui/src/i18n/locales/th.ts","src_lang":"en","text":"Use current chat for revision requests","text_hash":"9c551a423ae74aedaaa90e4df9899dbdc02f846d6ee058bf2576a812e2c52119","tgt_lang":"th","translated":"ใช้แชทปัจจุบันสำหรับคำขอแก้ไข","updated_at":"2026-06-16T14:17:16.747Z"} {"cache_key":"5ad0874d4b88cbec52942933ce469d2fd21f7a845e39319cd329b675581b5c3f","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.detailUpdatedValue","source_path":"ui/src/i18n/locales/th.ts","src_lang":"en","text":"Updated: {time}","text_hash":"5e72d5445f018c9d08aa34ae0178fb9aa49eea6a0afd0c8d379f20b7af3e8aa0","tgt_lang":"th","translated":"อัปเดตแล้ว: {time}","updated_at":"2026-06-16T14:17:16.747Z"} diff --git a/ui/src/i18n/locales/th.ts b/ui/src/i18n/locales/th.ts index f2b6f0d4b498..6a47377c5652 100644 --- a/ui/src/i18n/locales/th.ts +++ b/ui/src/i18n/locales/th.ts @@ -1297,8 +1297,8 @@ export const th: TranslationMap = { settings: "การตั้งค่าแชท", thinkingToggle: "สลับการแสดงผลการคิด/การทำงานของผู้ช่วย", toolCallsToggle: "สลับการแสดงการเรียกใช้ tool และผลลัพธ์ของ tool", - commentaryToggle: "Keep commentary after the final answer", - commentaryLabel: "Commentary", + commentaryToggle: "เก็บคำอธิบายหลังคำตอบสุดท้าย", + commentaryLabel: "คำอธิบาย", autoScrollMode: "โหมดเลื่อนอัตโนมัติ", autoScrollAlways: "เสมอ", autoScrollNearBottom: "ใกล้ด้านล่าง", From 0dcfbb09d807800585219a4363eb67438b006f40 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 01:08:40 +0000 Subject: [PATCH 15/29] chore(ui): refresh vi control ui locale --- ui/src/i18n/.i18n/vi.meta.json | 9 +++------ ui/src/i18n/.i18n/vi.tm.jsonl | 2 ++ ui/src/i18n/locales/vi.ts | 4 ++-- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/ui/src/i18n/.i18n/vi.meta.json b/ui/src/i18n/.i18n/vi.meta.json index bfd785497bac..4124afb161f8 100644 --- a/ui/src/i18n/.i18n/vi.meta.json +++ b/ui/src/i18n/.i18n/vi.meta.json @@ -1,14 +1,11 @@ { - "fallbackKeys": [ - "chat.commentaryLabel", - "chat.commentaryToggle" - ], - "generatedAt": "2026-06-30T23:31:04.422Z", + "fallbackKeys": [], + "generatedAt": "2026-07-01T01:08:40.347Z", "locale": "vi", "model": "claude-opus-4-8", "provider": "anthropic", "sourceHash": "2efa88c26ff88470a76b18ae218dd9c325165f98587f359ed51dbfb51a7d8428", "totalKeys": 1418, - "translatedKeys": 1416, + "translatedKeys": 1418, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/vi.tm.jsonl b/ui/src/i18n/.i18n/vi.tm.jsonl index 26a1ce268ce3..4b05023000af 100644 --- a/ui/src/i18n/.i18n/vi.tm.jsonl +++ b/ui/src/i18n/.i18n/vi.tm.jsonl @@ -25,6 +25,7 @@ {"cache_key":"3018088bf994335e79a2ccc0d40b848fc15f74fce6d15379e623d436f771b384","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.detailAutomationBoard","source_path":"ui/src/i18n/locales/vi.ts","src_lang":"en","text":"Board: {board}","text_hash":"96d7493589e40e17803b3bf643dff1b891a4ebf57f5d2b36af0a7ddd09e64b84","tgt_lang":"vi","translated":"Bảng: {board}","updated_at":"2026-06-16T14:17:10.180Z"} {"cache_key":"356b19869b1e2bf6eecb2fd221450379802c32d3234af567c267d65155ad3087","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.parentFolder","source_path":"ui/src/i18n/locales/vi.ts","src_lang":"en","text":"Parent folder","text_hash":"158f5a01ef8cfb1e6d91f8c321dd3a63f5e457f9650eecd662857701762bd31d","tgt_lang":"vi","translated":"Thư mục cha","updated_at":"2026-06-16T14:17:25.260Z"} {"cache_key":"364f0b235a275cee318660b3b1752b99c197a0317cc9e5e02de6215fcd552514","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.collapse","source_path":"ui/src/i18n/locales/vi.ts","src_lang":"en","text":"Collapse session workspace","text_hash":"b6174b882c37a98e640339d728652a0c1fa70d28ed53d8ccfb6e99363e86973b","tgt_lang":"vi","translated":"Thu gọn không gian làm việc phiên","updated_at":"2026-06-16T14:17:17.661Z"} +{"cache_key":"376ab10e7b6a84be26bacd3abcfb29611dcb2bb6a3a8b2764b683e25d1f00ed3","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.commentaryToggle","source_path":"ui/src/i18n/locales/vi.ts","src_lang":"en","text":"Keep commentary after the final answer","text_hash":"febdff0791d1ceeddb2b7596da8282f4dcfd5cff4cad11dea4c0a23944cd8ffe","tgt_lang":"vi","translated":"Giữ lại bình luận sau câu trả lời cuối cùng","updated_at":"2026-07-01T01:08:40.341Z"} {"cache_key":"37e1b972340d8c2b5d14328166f22dff7683c48e7e618680b94cf4a5846e3395","model":"gpt-5.5","provider":"openai","segment_id":"sessionsView.goalNote","source_path":"ui/src/i18n/locales/vi.ts","src_lang":"en","text":"Goal note","text_hash":"1afb7855a394ef7078728de1c804d6b995413db4eafe7d74190076cb9ed2c9f5","tgt_lang":"vi","translated":"Ghi chú mục tiêu","updated_at":"2026-05-29T21:02:23.226Z"} {"cache_key":"3c55b509d2b7d4d4af2d96aa24ce93cccfe8dc5524ed4c057044896b8b402f41","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.session","source_path":"ui/src/i18n/locales/vi.ts","src_lang":"en","text":"Session","text_hash":"6959b4159575d8dd76d9f3bbe2c6437904f861e7860c35abd18deffb1c3425a0","tgt_lang":"vi","translated":"Phiên","updated_at":"2026-06-16T14:17:25.260Z"} {"cache_key":"3faca6c079e1806d45c2701efe2a5d74cd24e183c62467548e9c8da220168eb2","model":"gpt-5.5","provider":"openai","segment_id":"tabs.skillWorkshop","source_path":"ui/src/i18n/locales/vi.ts","src_lang":"en","text":"Skill Workshop","text_hash":"3912c65bdd0a43563438762a43ecbd4b14637844a18decbf9249df73d21152a0","tgt_lang":"vi","translated":"Xưởng kỹ năng","updated_at":"2026-05-31T21:48:40.396Z"} @@ -67,6 +68,7 @@ {"cache_key":"8a18b8b08125b3c0dca240f99a72ebc937cc8cb549f8044f036601f453ae6080","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.layoutCompact","source_path":"ui/src/i18n/locales/vi.ts","src_lang":"en","text":"Compact card density","text_hash":"2f532993d5a6ccda4d758c7ecdda8bebaa857218045a3d4a011fec73d9728785","tgt_lang":"vi","translated":"Mật độ thẻ gọn","updated_at":"2026-06-17T14:17:26.553Z"} {"cache_key":"8b2cb3175fab20d1f0f424304d9bef3694acaf12c809fff1f64bbad91b3111c3","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.lastRefreshed","source_path":"ui/src/i18n/locales/vi.ts","src_lang":"en","text":"Updated {time}","text_hash":"2f87419441e6111b4d62893d3c4ef5ddeb2c8e1af82fabab6132856faf77f907","tgt_lang":"vi","translated":"Đã cập nhật {time}","updated_at":"2026-06-17T14:17:32.599Z"} {"cache_key":"8c254d6c4b07c9d90e1becd74f6cad2f80dda6899e3b89037df2f511c96e7864","model":"claude-opus-4-8","provider":"anthropic","segment_id":"cron.jobDetail.command","source_path":"ui/src/i18n/locales/vi.ts","src_lang":"en","text":"Command","text_hash":"713166971d730f81fcf8b757f2ea239d1a0360d9f74e8f5afe60fba97105879c","tgt_lang":"vi","translated":"Lệnh","updated_at":"2026-06-16T14:17:27.580Z"} +{"cache_key":"8fa558436942fe615594dfb23ad0ef1f0a32493303bdf62ebe1a0005ec392c0e","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.commentaryLabel","source_path":"ui/src/i18n/locales/vi.ts","src_lang":"en","text":"Commentary","text_hash":"4a7a75ab79cde05b0b1baa8f7a704c991586ea44d7e7793e57178ef56f778ff8","tgt_lang":"vi","translated":"Bình luận","updated_at":"2026-07-01T01:08:40.341Z"} {"cache_key":"91b4ce3ac5daa6eb31ddd0b846247227dbf6fd0a263302b0fc8e98c1ff0a8bcb","model":"gpt-5.5","provider":"openai","segment_id":"workboard.badgeWorkerLogs","source_path":"ui/src/i18n/locales/vi.ts","src_lang":"en","text":"{count} worker logs","text_hash":"2923e2a84e6ed0ca048d280206198b156da6148859e474498d335a44e323e0a8","tgt_lang":"vi","translated":"{count} nhật ký worker","updated_at":"2026-05-30T15:38:47.755Z"} {"cache_key":"95b11fdc7cc941dc3aec80d1229fe752549bf09007010227ed891541e909f07b","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.search","source_path":"ui/src/i18n/locales/vi.ts","src_lang":"en","text":"Search files","text_hash":"179fed85ec50a433bb23932745d18f1ade2f84a6ebe145b0025ed3ce5f89fd5a","tgt_lang":"vi","translated":"Tìm tệp","updated_at":"2026-06-16T14:17:25.260Z"} {"cache_key":"97256cc69ed01e0d33e68667ef78073de7338b91a2aecbbbdbcd5a5c3545ca44","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.emptyFilteredTitle","source_path":"ui/src/i18n/locales/vi.ts","src_lang":"en","text":"No cards match this view","text_hash":"a272617e183ba1dff3f7f140d0851b64baf95f4827ff729d23dfeb05c2069875","tgt_lang":"vi","translated":"Không có thẻ nào khớp với chế độ xem này","updated_at":"2026-06-17T14:17:32.599Z"} diff --git a/ui/src/i18n/locales/vi.ts b/ui/src/i18n/locales/vi.ts index b7d65c209939..02ad86788450 100644 --- a/ui/src/i18n/locales/vi.ts +++ b/ui/src/i18n/locales/vi.ts @@ -1321,8 +1321,8 @@ export const vi: TranslationMap = { settings: "Cài đặt trò chuyện", thinkingToggle: "Bật/tắt đầu ra suy nghĩ/đang xử lý của trợ lý", toolCallsToggle: "Bật/tắt lượt gọi công cụ và kết quả công cụ", - commentaryToggle: "Keep commentary after the final answer", - commentaryLabel: "Commentary", + commentaryToggle: "Giữ lại bình luận sau câu trả lời cuối cùng", + commentaryLabel: "Bình luận", autoScrollMode: "Chế độ tự động cuộn", autoScrollAlways: "Luôn luôn", autoScrollNearBottom: "Gần cuối", From db73ece2de77053433a767bcc587348d5a77fef1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 01:08:54 +0000 Subject: [PATCH 16/29] chore(ui): refresh nl control ui locale --- ui/src/i18n/.i18n/nl.meta.json | 9 +++------ ui/src/i18n/.i18n/nl.tm.jsonl | 2 ++ ui/src/i18n/locales/nl.ts | 4 ++-- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/ui/src/i18n/.i18n/nl.meta.json b/ui/src/i18n/.i18n/nl.meta.json index c3916c1bbcf9..4e8f8fd96f6e 100644 --- a/ui/src/i18n/.i18n/nl.meta.json +++ b/ui/src/i18n/.i18n/nl.meta.json @@ -1,14 +1,11 @@ { - "fallbackKeys": [ - "chat.commentaryLabel", - "chat.commentaryToggle" - ], - "generatedAt": "2026-06-30T23:31:04.524Z", + "fallbackKeys": [], + "generatedAt": "2026-07-01T01:08:53.816Z", "locale": "nl", "model": "claude-opus-4-8", "provider": "anthropic", "sourceHash": "2efa88c26ff88470a76b18ae218dd9c325165f98587f359ed51dbfb51a7d8428", "totalKeys": 1418, - "translatedKeys": 1416, + "translatedKeys": 1418, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/nl.tm.jsonl b/ui/src/i18n/.i18n/nl.tm.jsonl index 633b53e28e84..96072333e497 100644 --- a/ui/src/i18n/.i18n/nl.tm.jsonl +++ b/ui/src/i18n/.i18n/nl.tm.jsonl @@ -98,6 +98,7 @@ {"cache_key":"d2c4517a84df0c47908c3ec37889eea6510b4cd595b840fff8f0735998fa8b51","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.search","source_path":"ui/src/i18n/locales/nl.ts","src_lang":"en","text":"Search files","text_hash":"179fed85ec50a433bb23932745d18f1ade2f84a6ebe145b0025ed3ce5f89fd5a","tgt_lang":"nl","translated":"Bestanden zoeken","updated_at":"2026-06-16T14:18:24.009Z"} {"cache_key":"d771a510805ad2fb446a50f7455e0b7b059492b353f7cb092c792f33d685fc6b","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.detailWorkerLogs","source_path":"ui/src/i18n/locales/nl.ts","src_lang":"en","text":"Worker logs","text_hash":"67766b9f222a7ccdae6beb7d6e7877d1a13abb8a346a8c5c803a4380bdf851b1","tgt_lang":"nl","translated":"Worker-logboeken","updated_at":"2026-06-16T14:18:09.152Z"} {"cache_key":"d78e3bbfa65af411c29d5f738cd892a482898a079455ce8e4a457ebd7edd8ba7","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.healthReadyUnassigned","source_path":"ui/src/i18n/locales/nl.ts","src_lang":"en","text":"ready unassigned","text_hash":"1e1a31a02e9da6ffa99a459bfd82e5af723794c2175405a5a0d6a32afa955167","tgt_lang":"nl","translated":"klaar niet-toegewezen","updated_at":"2026-06-17T14:17:36.035Z"} +{"cache_key":"d8a9c3f3554e93601780d385dc3e7ba7de95ae6c6ba4b9b84e3a5ce43cadf36f","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.commentaryToggle","source_path":"ui/src/i18n/locales/nl.ts","src_lang":"en","text":"Keep commentary after the final answer","text_hash":"febdff0791d1ceeddb2b7596da8282f4dcfd5cff4cad11dea4c0a23944cd8ffe","tgt_lang":"nl","translated":"Commentaar na het uiteindelijke antwoord behouden","updated_at":"2026-07-01T01:08:53.808Z"} {"cache_key":"db9ddb7605768fa72b2eddf2504d6719a2b908acb788f094e586d28d7a247a8e","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.detailRun","source_path":"ui/src/i18n/locales/nl.ts","src_lang":"en","text":"Run","text_hash":"00d60e31a4e6b8344d4201f25a6a7dee770713107f6d097abb01559d32b17f26","tgt_lang":"nl","translated":"Uitvoering","updated_at":"2026-06-16T14:18:09.152Z"} {"cache_key":"dffb6d4089b04b96d2b16470e1cde9b45856846fe44432ced18a23b11312dfbe","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.truncated","source_path":"ui/src/i18n/locales/nl.ts","src_lang":"en","text":"Showing the first matching files. Refine the search to narrow results.","text_hash":"62005877ff0fc1f73ce05ca4c459157c57a8c57a3443245b1df4d3b033df98e9","tgt_lang":"nl","translated":"De eerste overeenkomende bestanden worden getoond. Verfijn de zoekopdracht om de resultaten te beperken.","updated_at":"2026-06-16T14:18:24.009Z"} {"cache_key":"e116b4ffade997d2825031156aa652af9a11e7bacbc0fde0240cd39021afab09","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.dependencyStatusMissing","source_path":"ui/src/i18n/locales/nl.ts","src_lang":"en","text":"Missing","text_hash":"6be36ca49ee85210c5d1ad9c377d90a9859c66d889110a2a5b0dccd390d12e20","tgt_lang":"nl","translated":"Ontbreekt","updated_at":"2026-06-16T14:18:17.492Z"} @@ -106,6 +107,7 @@ {"cache_key":"e7b5d8bc7926048628a8552c9eb53822eec42b7fd30223690e9dbc1ea08f6829","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.detailAutomationSkills","source_path":"ui/src/i18n/locales/nl.ts","src_lang":"en","text":"Skills: {skills}","text_hash":"4788d5f9db66e1421a762bbd942c64450c73d2145a6ef929ce32a919a0f2e3a1","tgt_lang":"nl","translated":"Skills: {skills}","updated_at":"2026-06-16T14:18:09.152Z"} {"cache_key":"eab2584537fec740e4a1ec35af816c4cd3ad6dd020ab7502e89592a8a2e9d3e4","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.viewRunning","source_path":"ui/src/i18n/locales/nl.ts","src_lang":"en","text":"Running","text_hash":"f4ccae29e1bb0c20a124570a1b43f4347ea94bba9f84ffdfddd9c7445b126128","tgt_lang":"nl","translated":"Actief","updated_at":"2026-06-17T14:17:30.702Z"} {"cache_key":"eb126db2ff8c08ead9df247ca29242ada9f2a2a2a3aad6e34c3adf355a5c2b73","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.viewBlocked","source_path":"ui/src/i18n/locales/nl.ts","src_lang":"en","text":"Blocked","text_hash":"18f2a0947f9d6523991b29b450307f22773f57d65f7efb98d48a167df04d6b1d","tgt_lang":"nl","translated":"Geblokkeerd","updated_at":"2026-06-17T14:17:30.702Z"} +{"cache_key":"ecf55f10effe7949c5b432c5714b8c56a9e583055e54b83d17e3ece3e1e8b45d","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.commentaryLabel","source_path":"ui/src/i18n/locales/nl.ts","src_lang":"en","text":"Commentary","text_hash":"4a7a75ab79cde05b0b1baa8f7a704c991586ea44d7e7793e57178ef56f778ff8","tgt_lang":"nl","translated":"Commentaar","updated_at":"2026-07-01T01:08:53.808Z"} {"cache_key":"ed2dd36ed7de22efa8a3b9b41794981db00f932f76405c9f4a683d1bfa4ee0c5","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.detailAddNote","source_path":"ui/src/i18n/locales/nl.ts","src_lang":"en","text":"Add note","text_hash":"63565c0485fec4f743719849734553a5d7947f5962ec9e831e3bce131b3c47fb","tgt_lang":"nl","translated":"Notitie toevoegen","updated_at":"2026-06-16T14:18:17.492Z"} {"cache_key":"efa56d3f17acb13447607a981a218b6c26fcb6406317d6564b1a43ab259dcf5a","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.dependenciesBlockedTitle","source_path":"ui/src/i18n/locales/nl.ts","src_lang":"en","text":"Waiting on dependencies: {parents}.","text_hash":"50fb8f9b1326b69bd67d25583ddb4f70b9d75ae6e3ff8a9056a9361daa4b7d8b","tgt_lang":"nl","translated":"Wachten op afhankelijkheden: {parents}.","updated_at":"2026-06-16T14:18:17.492Z"} {"cache_key":"f072b45537bd11ae936b4b6a6e2116b3558e92f678fd4157dee5c9a9e9fa3a5d","model":"gpt-5.5","provider":"openai","segment_id":"chat.toolCards.toolError","source_path":"ui/src/i18n/locales/nl.ts","src_lang":"en","text":"Tool error","text_hash":"a6c64c286a8795034ac5030b74633d3b476b5375e094485698b982879b0bb617","tgt_lang":"nl","translated":"Toolfout","updated_at":"2026-05-31T06:44:10.180Z"} diff --git a/ui/src/i18n/locales/nl.ts b/ui/src/i18n/locales/nl.ts index c6e6fd717958..7ae822bf0135 100644 --- a/ui/src/i18n/locales/nl.ts +++ b/ui/src/i18n/locales/nl.ts @@ -1335,8 +1335,8 @@ export const nl: TranslationMap = { settings: "Chatinstellingen", thinkingToggle: "Denken-/werken-output van assistent schakelen", toolCallsToggle: "Tool-aanroepen en toolresultaten schakelen", - commentaryToggle: "Keep commentary after the final answer", - commentaryLabel: "Commentary", + commentaryToggle: "Commentaar na het uiteindelijke antwoord behouden", + commentaryLabel: "Commentaar", autoScrollMode: "Automatisch scrollen", autoScrollAlways: "Altijd", autoScrollNearBottom: "Bijna onderaan", From 21e6fc948b1977b5702ab4c981dacf4d0b3b6de6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 01:08:55 +0000 Subject: [PATCH 17/29] chore(ui): refresh fa control ui locale --- ui/src/i18n/.i18n/fa.meta.json | 9 +++------ ui/src/i18n/.i18n/fa.tm.jsonl | 2 ++ ui/src/i18n/locales/fa.ts | 4 ++-- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/ui/src/i18n/.i18n/fa.meta.json b/ui/src/i18n/.i18n/fa.meta.json index 985c71bca573..0eb5c2c3d60b 100644 --- a/ui/src/i18n/.i18n/fa.meta.json +++ b/ui/src/i18n/.i18n/fa.meta.json @@ -1,14 +1,11 @@ { - "fallbackKeys": [ - "chat.commentaryLabel", - "chat.commentaryToggle" - ], - "generatedAt": "2026-06-30T23:31:04.632Z", + "fallbackKeys": [], + "generatedAt": "2026-07-01T01:08:55.391Z", "locale": "fa", "model": "claude-opus-4-8", "provider": "anthropic", "sourceHash": "2efa88c26ff88470a76b18ae218dd9c325165f98587f359ed51dbfb51a7d8428", "totalKeys": 1418, - "translatedKeys": 1416, + "translatedKeys": 1418, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/fa.tm.jsonl b/ui/src/i18n/.i18n/fa.tm.jsonl index 20ea3ac4afaa..68f60e94282f 100644 --- a/ui/src/i18n/.i18n/fa.tm.jsonl +++ b/ui/src/i18n/.i18n/fa.tm.jsonl @@ -15,6 +15,7 @@ {"cache_key":"27bddaa81a39838a7db147c71a1cf70ecaf8f77fdb02dc4044429510d81a2af8","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.workspace","source_path":"ui/src/i18n/locales/fa.ts","src_lang":"en","text":"Session","text_hash":"6959b4159575d8dd76d9f3bbe2c6437904f861e7860c35abd18deffb1c3425a0","tgt_lang":"fa","translated":"نشست","updated_at":"2026-06-16T14:18:40.803Z"} {"cache_key":"288b7d325a0a012c988365dc4f25865247bd4e9ad36b28fe64602aa592730e03","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.autoRefresh60s","source_path":"ui/src/i18n/locales/fa.ts","src_lang":"en","text":"60s","text_hash":"f79f071ab5b033ca8fb42c077f39708930d194b18f4608eb26ac1d9665a8836f","tgt_lang":"fa","translated":"۶۰ث","updated_at":"2026-06-17T14:17:52.321Z"} {"cache_key":"2a0c4156e119cbbee60b0fcd324e244a6a5d929ee42825aa597fb1bc0d96689a","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.dependencyMissing","source_path":"ui/src/i18n/locales/fa.ts","src_lang":"en","text":"{parent} (missing)","text_hash":"8daa419059727391c01e3b7021e05d8d70b4da67f9c57cd2d80f302af77aac53","tgt_lang":"fa","translated":"{parent} (موجود نیست)","updated_at":"2026-06-16T14:18:40.803Z"} +{"cache_key":"2a518a5678410988058a5f5bb49f7a7b252650d0ca7de4c6f5a5d6d15a3a1315","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.commentaryLabel","source_path":"ui/src/i18n/locales/fa.ts","src_lang":"en","text":"Commentary","text_hash":"4a7a75ab79cde05b0b1baa8f7a704c991586ea44d7e7793e57178ef56f778ff8","tgt_lang":"fa","translated":"توضیحات","updated_at":"2026-07-01T01:08:55.386Z"} {"cache_key":"2d11072c42f933f7842f3cceb8de9f651f2b179dd0c953f0f596df8b1664bf83","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.detailUpdated","source_path":"ui/src/i18n/locales/fa.ts","src_lang":"en","text":"Updated","text_hash":"3a5ecca188c0579c00ee24cf3cab21bd02c15a06f7a70cc8e0a8ff2381dcbbfd","tgt_lang":"fa","translated":"به‌روزرسانی شده","updated_at":"2026-06-16T14:18:33.052Z"} {"cache_key":"2fd4392dfaf76c78038a7ddd7fd6dced4b6ec2dbed11056e095fe62fd076c629","model":"claude-opus-4-8","provider":"anthropic","segment_id":"cron.jobDetail.cwd","source_path":"ui/src/i18n/locales/fa.ts","src_lang":"en","text":"CWD","text_hash":"0217f1cb7725737f15a6710df3bcfa3bc10a239f0f7801ec3d7168e675f5ebd6","tgt_lang":"fa","translated":"CWD","updated_at":"2026-06-16T14:18:51.277Z"} {"cache_key":"3043c3a4d9b51e83d569074ad899dbf4bd1a12653772146156a62646579a936b","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.healthRunning","source_path":"ui/src/i18n/locales/fa.ts","src_lang":"en","text":"running","text_hash":"c071cf5f5ed6f884cc70155b6f05f755fd46a302d05e4261b7e92ce878bbfed8","tgt_lang":"fa","translated":"در حال اجرا","updated_at":"2026-06-17T14:17:52.321Z"} @@ -34,6 +35,7 @@ {"cache_key":"4f05e9e5fbf924ac2f46d0911f2a6cb352db867595de5e9f02fa06e83f15e8bf","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.emptyFilteredHint","source_path":"ui/src/i18n/locales/fa.ts","src_lang":"en","text":"Change the view, search, priority, agent, or archive filter.","text_hash":"049dfae940263ace9707334af06b298c1223c38a449b1cec5a712553badebbd0","tgt_lang":"fa","translated":"نما، جست‌وجو، اولویت، عامل یا فیلتر بایگانی را تغییر دهید.","updated_at":"2026-06-17T14:17:52.321Z"} {"cache_key":"5041238e315bbd454dc532fba706052bdb36409f20e76ee2f5990aacacb0bb5d","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.detailAutomationWorkspace","source_path":"ui/src/i18n/locales/fa.ts","src_lang":"en","text":"Workspace: {workspace}","text_hash":"17f5e696e557a646a9003fc8448f6f6761f5fe6bdf7478f750f471496e87c17b","tgt_lang":"fa","translated":"فضای کاری: {workspace}","updated_at":"2026-06-16T14:18:33.052Z"} {"cache_key":"52148bd591542bf023502ed432ff714e900777dc4091852c878236b87f216a5b","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.badgeHeartbeat","source_path":"ui/src/i18n/locales/fa.ts","src_lang":"en","text":"heartbeat {age}","text_hash":"000637b3800ae069edbbe207cfad0a3f5037f06e9661ee89d70a1dfe6f404485","tgt_lang":"fa","translated":"ضربان {age}","updated_at":"2026-06-17T14:17:52.321Z"} +{"cache_key":"52c71ec0fa1f6d7d7852134c41e334b08dd2785e3400941fb4c6d58347a3b3c6","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.commentaryToggle","source_path":"ui/src/i18n/locales/fa.ts","src_lang":"en","text":"Keep commentary after the final answer","text_hash":"febdff0791d1ceeddb2b7596da8282f4dcfd5cff4cad11dea4c0a23944cd8ffe","tgt_lang":"fa","translated":"حفظ توضیحات پس از پاسخ نهایی","updated_at":"2026-07-01T01:08:55.386Z"} {"cache_key":"540cc2e8155aae8b1273f29ebbc09c53a7c4789810c7d914400b0dc22757b6cf","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.session","source_path":"ui/src/i18n/locales/fa.ts","src_lang":"en","text":"Session","text_hash":"6959b4159575d8dd76d9f3bbe2c6437904f861e7860c35abd18deffb1c3425a0","tgt_lang":"fa","translated":"جلسه","updated_at":"2026-06-16T14:18:48.730Z"} {"cache_key":"54c8ecb7d58cc6d0d7ffac0766b04a2def135e2b9c3e86bf6bb3fcc50df0b1f6","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.agentFilterConfiguredDefaultHelp","source_path":"ui/src/i18n/locales/fa.ts","src_lang":"en","text":"Cards explicitly assigned to the configured default agent.","text_hash":"9bb80530da1dfd473936d94642b83cc668b7362cb65675a565f17569937af92f","tgt_lang":"fa","translated":"کارت‌هایی که صراحتاً به عامل پیش‌فرض پیکربندی‌شده تخصیص یافته‌اند.","updated_at":"2026-06-17T14:17:45.319Z"} {"cache_key":"559e2b5c160f48b34db11fc54d811601888ed2667203f5ee61798fdcb822d07a","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.viewAll","source_path":"ui/src/i18n/locales/fa.ts","src_lang":"en","text":"All cards","text_hash":"2306120917506b5998ec702f3661068b102dac538ba4c9e4634d65fe33eea98a","tgt_lang":"fa","translated":"همه کارت‌ها","updated_at":"2026-06-17T14:17:45.319Z"} diff --git a/ui/src/i18n/locales/fa.ts b/ui/src/i18n/locales/fa.ts index 694b5abaae8d..8f8ddbd3fe4c 100644 --- a/ui/src/i18n/locales/fa.ts +++ b/ui/src/i18n/locales/fa.ts @@ -1331,8 +1331,8 @@ export const fa: TranslationMap = { settings: "تنظیمات چت", thinkingToggle: "تغییر وضعیت خروجی فکر/کار دستیار", toolCallsToggle: "تغییر وضعیت نمایش فراخوانی‌های ابزار و نتایج ابزار", - commentaryToggle: "Keep commentary after the final answer", - commentaryLabel: "Commentary", + commentaryToggle: "حفظ توضیحات پس از پاسخ نهایی", + commentaryLabel: "توضیحات", autoScrollMode: "حالت پیمایش خودکار", autoScrollAlways: "همیشه", autoScrollNearBottom: "نزدیک پایین", From 3eaaa2ca3c5e7ef2bb6a7ffd106c82559583a0d7 Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Tue, 30 Jun 2026 18:10:04 -0700 Subject: [PATCH 18/29] fix(tui): correct disconnect copy for device scope upgrades (#98144) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(tui): correct disconnect copy for device scope upgrades On disconnect, the TUI told users "Pairing required. Run `openclaw devices list`, approve your request ID, then reconnect." This is misleading: the gateway is asking for a device *scope upgrade* (the device is already paired), and "pairing" points users at `openclaw pairing`, which only handles chat DM pairing — a different subsystem. - Reword the hint to name the scope upgrade and the actual recovery command (`openclaw devices approve --latest`), including the `--token`/`--password` escape hatch for when the device can't approve its own upgrade. - Also match the gateway's "scope upgrade" disconnect reason, not just "pairing required". AI-assisted (Claude Code). * fix(tui): clarify device approval preview hint --- src/tui/tui.test.ts | 19 ++++++++++++++++--- src/tui/tui.ts | 9 ++++++--- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/tui/tui.test.ts b/src/tui/tui.test.ts index 1f90313499df..1d728a1047eb 100644 --- a/src/tui/tui.test.ts +++ b/src/tui/tui.test.ts @@ -346,11 +346,24 @@ describe("resolveInitialTuiAgentId", () => { }); describe("resolveGatewayDisconnectState", () => { - it("returns pairing recovery guidance when disconnect reason requires pairing", () => { + it("returns scope-upgrade recovery guidance when disconnect reason requires pairing", () => { const state = resolveGatewayDisconnectState("gateway closed (1008): pairing required"); expect(state.connectionStatus).toContain("pairing required"); - expect(state.activityStatus).toBe("pairing required: run openclaw devices list"); - expect(state.pairingHint).toContain("openclaw devices list"); + expect(state.activityStatus).toBe("device approval needed: preview latest request"); + expect(state.pairingHint).toContain("openclaw devices approve --latest"); + expect(state.pairingHint).toContain("openclaw devices approve "); + expect(state.pairingHint).toContain("--token"); + // Must steer users to `devices`, not the unrelated chat-DM `pairing` command. + expect(state.pairingHint).not.toContain("openclaw pairing"); + }); + + it("returns the same guidance when the gateway reports a pending scope upgrade", () => { + const state = resolveGatewayDisconnectState( + "gateway closed (1008): scope upgrade pending approval", + ); + expect(state.activityStatus).toBe("device approval needed: preview latest request"); + expect(state.pairingHint).toContain("openclaw devices approve --latest"); + expect(state.pairingHint).toContain("openclaw devices approve "); }); it("falls back to idle for generic disconnect reasons", () => { diff --git a/src/tui/tui.ts b/src/tui/tui.ts index 0effaa3507ff..369636c5db17 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -230,12 +230,15 @@ export function resolveGatewayDisconnectState(reason?: string): { pairingHint?: string; } { const reasonLabel = reason?.trim() ? reason.trim() : "closed"; - if (/pairing required/i.test(reasonLabel)) { + // Covers both "pairing required" and a pending "scope upgrade" for a paired device. + if (/pairing required|scope upgrade/i.test(reasonLabel)) { return { connectionStatus: `gateway disconnected: ${reasonLabel}`, - activityStatus: "pairing required: run openclaw devices list", + activityStatus: "device approval needed: preview latest request", pairingHint: - "Pairing required. Run `openclaw devices list`, approve your request ID, then reconnect.", + "Device approval needed. Run `openclaw devices approve --latest` to preview the pending request, " + + "then rerun the printed `openclaw devices approve ` command " + + "(reuse `--token` or other auth flags if needed), then reconnect.", }; } return { From b3b51b0c91855ed370d9c1a77b5bdf212fd1fe87 Mon Sep 17 00:00:00 2001 From: Marvinthebored Date: Wed, 1 Jul 2026 09:12:07 +0800 Subject: [PATCH 19/29] fix(anthropic): surface Discord pre-tool commentary Route Anthropic pre-tool narration through the commentary progress lane, preserve shared channel progress defaults, and keep Discord/Telegram reasoning gates explicit. Thanks @Marvinthebored! --- docs/.generated/config-baseline.sha256 | 6 +- docs/concepts/streaming.md | 16 + docs/docs_map.md | 1 + .../monitor/message-handler.draft-preview.ts | 10 +- .../monitor/message-handler.process.test.ts | 387 ++++++++------ .../src/monitor/message-handler.process.ts | 251 ++++++++- .../telegram/src/bot-message-dispatch.test.ts | 30 ++ .../telegram/src/bot-message-dispatch.ts | 1 + .../gpt55-thinking-visibility-switch.yaml | 2 +- scripts/plugin-sdk-surface-report.mjs | 14 +- src/agents/anthropic-transport-stream.test.ts | 188 ++++++- src/agents/anthropic-transport-stream.ts | 68 ++- src/agents/embedded-agent-payloads.ts | 2 + .../delivery-evidence.ts | 2 + src/agents/embedded-agent-runner/run.ts | 1 + .../embedded-agent-runner/run/attempt.ts | 1 + .../embedded-agent-runner/run/params.ts | 14 +- .../embedded-agent-runner/run/payloads.ts | 2 + .../stream-resolution.ts | 11 +- src/agents/embedded-agent-runner/types.ts | 2 + ...-agent-subscribe.handlers.messages.test.ts | 166 +++++- ...edded-agent-subscribe.handlers.messages.ts | 122 ++++- ...sion.subscribeembeddedagentsession.test.ts | 64 +++ ...hholds-anthropic-pretool-narration.test.ts | 100 ++++ src/agents/embedded-agent-subscribe.ts | 26 +- src/agents/embedded-agent-subscribe.types.ts | 15 +- src/agents/embedded-agent-utils.test.ts | 7 +- src/agents/embedded-agent-utils.ts | 15 +- src/agents/runtime-plan/types.ts | 2 + src/agents/transport-stream-shared.ts | 12 + src/auto-reply/get-reply-options.types.ts | 12 +- src/auto-reply/reply-payload.ts | 2 + .../reply/agent-runner-execution.test.ts | 38 ++ .../reply/agent-runner-execution.ts | 489 ++++++++++-------- src/auto-reply/reply/block-reply-coalescer.ts | 7 + .../reply/dispatch-from-config.test.ts | 125 +++++ src/auto-reply/reply/dispatch-from-config.ts | 30 +- src/auto-reply/reply/reply-delivery.ts | 9 +- .../progress-draft-compositor.test.ts | 268 +++++----- src/channels/progress-draft-compositor.ts | 68 ++- src/config/types.discord.ts | 5 +- src/config/zod-schema.providers-core.ts | 5 +- src/plugin-sdk/reply-payload.ts | 2 + src/talk/agent-consult-tool.ts | 4 +- 44 files changed, 1960 insertions(+), 642 deletions(-) create mode 100644 src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.withholds-anthropic-pretool-narration.test.ts diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index dbc4710eaba5..4b1d3894a512 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -d88b36f186301f29b2ec01b1580609b6709b7b537ed461be84a14a66c80c5544 config-baseline.json -16ea2dff07075ea8392bbf0cee461cd2f42fb54ade2f1a09a950fd1086e82a2f config-baseline.core.json -671979e86e4c4f59415d0a20879e838f9bbd883b3d29eeb02cb5131db8d187fe config-baseline.channel.json +5ce9c4b232d6472204e91187847bdb3204afc9e53833f6d92cf8dada2dc2ba67 config-baseline.json +00242f93938579d3ff3130f8d42e08a423e43dc7a73e3b5e5afb45b80c78d25a config-baseline.core.json +4982a9d7d070b37e64048697b0611e7ce34a1059ba0735106c07f17666ed9fc2 config-baseline.channel.json 21ce3d97ac3a83323fa29ddc35045c04f6eae3040c2c5515077f543b4608fd12 config-baseline.plugin.json diff --git a/docs/concepts/streaming.md b/docs/concepts/streaming.md index fdf453608ee4..639be19d32cd 100644 --- a/docs/concepts/streaming.md +++ b/docs/concepts/streaming.md @@ -214,6 +214,22 @@ Supported surfaces: - To keep preview streaming but hide tool-progress lines, set `streaming.preview.toolProgress` to `false` for that channel. To keep tool-progress lines visible while hiding command/exec text, set `streaming.preview.commandText` to `"status"` or `streaming.progress.commandText` to `"status"`; the default is `"raw"` to preserve released behavior. This policy is shared by draft/progress channels that use OpenClaw's compact progress renderer, including Discord, Matrix, Microsoft Teams, Mattermost, Slack draft previews, and Telegram. To disable preview edits entirely, set `streaming.mode` to `off`. - Telegram selected quote replies are an exception: when `replyToMode` is not `"off"` and selected quote text is present, OpenClaw skips the answer preview stream for that turn so tool-progress preview lines cannot render. Current-message replies without selected quote text still keep preview streaming. See [Telegram channel docs](/channels/telegram) for details. +### Commentary progress lane + +Beyond tool-progress, the compact progress renderer can surface one more lane in the draft: + +- **`streaming.progress.commentary`** — render the model's pre-tool **commentary** (💬) — short "I'll check… then…" narration — interleaved with tool lines in the progress draft. + +```json +{ + "channels": { + "discord": { + "streaming": { "mode": "progress", "progress": { "commentary": true } } + } + } +} +``` + Keep progress lines visible but hide raw command/exec text: ```json diff --git a/docs/docs_map.md b/docs/docs_map.md index e1236feac727..2db5ef40aa74 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -2829,6 +2829,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H3: Channel mapping - H3: Runtime behavior - H3: Tool-progress preview updates + - H3: Commentary progress lane - H2: Related ## concepts/system-prompt.md diff --git a/extensions/discord/src/monitor/message-handler.draft-preview.ts b/extensions/discord/src/monitor/message-handler.draft-preview.ts index f69eec8e53c5..2cc8abe98452 100644 --- a/extensions/discord/src/monitor/message-handler.draft-preview.ts +++ b/extensions/discord/src/monitor/message-handler.draft-preview.ts @@ -44,8 +44,10 @@ export function createDiscordDraftPreviewController(params: { const accountBlockStreamingEnabled = resolveChannelStreamingBlockEnabled(params.discordConfig) ?? params.cfg.agents?.defaults?.blockStreamingDefault === "on"; + const canStreamProgressDraftForToolOnlySource = + params.sourceRepliesAreToolOnly && discordStreamMode === "progress"; const canStreamDraft = - !params.sourceRepliesAreToolOnly && + (!params.sourceRepliesAreToolOnly || canStreamProgressDraftForToolOnlySource) && discordStreamMode !== "off" && !accountBlockStreamingEnabled; const draftStream = canStreamDraft @@ -89,6 +91,10 @@ export function createDiscordDraftPreviewController(params: { mode: discordStreamMode, active: Boolean(draftStream), seed: progressSeed, + reasoningLinePrefix: "🧠 ", + commentaryLinePrefix: "💬 ", + reasoningGate: true, + commentaryItalics: false, update: async (previewText, options) => { lastPartialText = previewText; draftText = previewText; @@ -254,6 +260,8 @@ export function createDiscordDraftPreviewController(params: { }); }, handleAssistantMessageBoundary() { + // Queued/followup turns need a fresh progress draft after the primary final. + progressDraft.beginNewTurn(); if (discordStreamMode === "progress") { return; } diff --git a/extensions/discord/src/monitor/message-handler.process.test.ts b/extensions/discord/src/monitor/message-handler.process.test.ts index 5f4608bd6a3f..a5dea71ff254 100644 --- a/extensions/discord/src/monitor/message-handler.process.test.ts +++ b/extensions/discord/src/monitor/message-handler.process.test.ts @@ -132,6 +132,7 @@ type DispatchInboundParams = { onReasoningStream?: (payload?: { text?: string; isReasoningSnapshot?: boolean; + requiresReasoningProgressOptIn?: boolean; }) => Promise | void; onReasoningEnd?: () => Promise | void; onToolStart?: (payload: { @@ -142,7 +143,6 @@ type DispatchInboundParams = { }) => Promise | void; onItemEvent?: (payload: { itemId?: string; - toolCallId?: string; kind?: string; progressText?: string; summary?: string; @@ -157,8 +157,6 @@ type DispatchInboundParams = { }) => Promise | void; onApprovalEvent?: (payload: { phase?: string; command?: string }) => Promise | void; onCommandOutput?: (payload: { - itemId?: string; - toolCallId?: string; phase?: string; name?: string; title?: string; @@ -185,7 +183,6 @@ type DispatchInboundParams = { onPartialReply?: (payload: { text?: string }) => Promise | void; onAssistantMessageStart?: () => Promise | void; allowProgressCallbacksWhenSourceDeliverySuppressed?: boolean; - allowToolLifecycleWhenProgressHidden?: boolean; onTypingCleanup?: () => Promise | void; }; }; @@ -747,6 +744,27 @@ function expectSinglePreviewEdit() { expect(deliverDiscordReply).not.toHaveBeenCalled(); } +function expectProgressSummaryEdit(...parts: string[]) { + const call = firstMockCall(editMessageDiscord, "summary edit"); + expect(call[0]).toBe("c1"); + expect(call[1]).toBe("preview-1"); + const edit = requireRecord(call[2], "summary edit body"); + const content = String(edit.content); + expect(content.startsWith("-# ")).toBe(true); + for (const part of parts) { + expect(content).toContain(part); + } +} + +function expectFreshFinalText(text: string) { + const finalParams = deliverDiscordReply.mock.calls + .map((call) => requireRecord(call[0], "deliverDiscordReply params")) + .find((params) => params.kind === "final"); + expect(finalParams).toBeDefined(); + const replies = (finalParams as { replies?: Array<{ text?: string }> }).replies; + expect(replies?.[0]?.text).toBe(text); +} + describe("processDiscordMessage ack reactions", () => { it("drops bot-loop-suppressed messages before Discord side effects", async () => { const botLoopProtection: ChannelBotLoopProtectionFacts = { @@ -830,39 +848,6 @@ describe("processDiscordMessage ack reactions", () => { }); }); - it("records accepted mention ingress before acking and dispatching", async () => { - const events: string[] = []; - recordInboundSession.mockImplementationOnce(async () => { - events.push("record"); - }); - sendMocks.reactMessageDiscord.mockImplementationOnce(async () => { - events.push("ack"); - }); - dispatchInboundMessage.mockImplementationOnce(async () => { - events.push("dispatch"); - return createNoQueuedDispatchResult(); - }); - const ctx = await createAutomaticSourceDeliveryContext({ - accountId: "ops", - shouldRequireMention: true, - effectiveWasMentioned: true, - route: { - agentId: "main", - channel: "discord", - accountId: "ops", - sessionKey: "agent:main:discord:channel:c1", - mainSessionKey: "agent:main:main", - }, - }); - - await runProcessDiscordMessage(ctx); - - expect(events).toEqual(["record", "ack", "dispatch"]); - expect(recordInboundSession).toHaveBeenCalledTimes(1); - expect(sendMocks.reactMessageDiscord).toHaveBeenCalled(); - expect(dispatchInboundMessage).toHaveBeenCalledTimes(1); - }); - it("uses preflight-resolved messageChannelId when message.channelId is missing", async () => { const ctx = await createAutomaticSourceDeliveryContext({ message: { @@ -1024,7 +1009,6 @@ describe("processDiscordMessage ack reactions", () => { await runProcessDiscordMessage(ctx); - expect(getLastDispatchReplyOptions()?.allowToolLifecycleWhenProgressHidden).toBe(true); const emojis = getReactionEmojis(); expect(emojis).toContain("👀"); expect(emojis).toContain(DEFAULT_EMOJIS.done); @@ -1193,7 +1177,6 @@ describe("processDiscordMessage ack reactions", () => { await runProcessDiscordMessage(ctx); - expect(getLastDispatchReplyOptions()?.allowToolLifecycleWhenProgressHidden).toBeUndefined(); expect(getReactionEmojis()).toEqual(["👀"]); }); @@ -2129,8 +2112,100 @@ describe("processDiscordMessage draft streaming", () => { const updates = draftStream.update.mock.calls.map((call) => call[0]); expect(updates).toEqual(["Pinching\n\n🛠️ Exec\n• exec done"]); - expectPreviewEditContent("done"); - expect(deliverDiscordReply).not.toHaveBeenCalled(); + expectProgressSummaryEdit("🛠️ 1 tool call"); + expectFreshFinalText("done"); + }); + + it("counts window thinking bursts closed by a tool call when no end event fires", async () => { + // deepseek streams reasoning then a tool call with no thinking_end between + // bursts; the tool-start boundary (and the summary flush) must still tally. + createMockDraftStreamForTest(); + + dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => { + await params?.replyOptions?.onReasoningStream?.({ text: "Listing the workspace" }); + await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" }); + await params?.replyOptions?.onReasoningStream?.({ text: "Picking the largest" }); + await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" }); + await params?.replyOptions?.onReasoningStream?.({ text: "Composing the answer" }); + await params?.dispatcher.sendFinalReply({ text: "done" }); + return { queuedFinal: true, counts: { final: 1, tool: 0, block: 0 } }; + }); + + const ctx = await createAutomaticSourceDeliveryContext({ + discordConfig: { + streaming: { mode: "progress", progress: { label: "Shelling", thinking: true } }, + }, + }); + + await runProcessDiscordMessage(ctx); + + // 2 bursts closed by tool calls + 1 trailing burst flushed at summary. + expectProgressSummaryEdit("🧠 3 thoughts", "🛠️ 2 tool calls"); + expectFreshFinalText("done"); + }); + + it("counts window thinking bursts in the collapse summary", async () => { + createMockDraftStreamForTest(); + + dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => { + await params?.replyOptions?.onReasoningStream?.({ text: "Planning the survey" }); + await params?.replyOptions?.onReasoningEnd?.(); + await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" }); + await params?.replyOptions?.onReasoningStream?.({ text: "Reading results" }); + await params?.replyOptions?.onReasoningEnd?.(); + // A boundary without a preceding burst must not inflate the count. + await params?.replyOptions?.onReasoningEnd?.(); + await params?.dispatcher.sendFinalReply({ text: "done" }); + return { queuedFinal: true, counts: { final: 1, tool: 0, block: 0 } }; + }); + + const ctx = await createAutomaticSourceDeliveryContext({ + discordConfig: { + streaming: { mode: "progress", progress: { label: "Shelling", thinking: true } }, + }, + }); + + await runProcessDiscordMessage(ctx); + + expectProgressSummaryEdit("🧠 2 thoughts", "🛠️ 1 tool call"); + expectFreshFinalText("done"); + }); + + it("counts distinct narration notes in the collapse summary", async () => { + createMockDraftStreamForTest(); + + dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => { + await params?.replyOptions?.onItemEvent?.({ + kind: "preamble", + itemId: "p1", + progressText: "Listing the workspace", + }); + // Re-fire of the same note (delta/snapshot) must not inflate the count. + await params?.replyOptions?.onItemEvent?.({ + kind: "preamble", + itemId: "p1", + progressText: "Listing the workspace files", + }); + await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" }); + await params?.replyOptions?.onItemEvent?.({ + kind: "preamble", + itemId: "p2", + progressText: "Composing the answer", + }); + await params?.dispatcher.sendFinalReply({ text: "done" }); + return { queuedFinal: true, counts: { final: 1, tool: 0, block: 0 } }; + }); + + const ctx = await createAutomaticSourceDeliveryContext({ + discordConfig: { + streaming: { mode: "progress", progress: { label: "Shelling", commentary: true } }, + }, + }); + + await runProcessDiscordMessage(ctx); + + expectProgressSummaryEdit("💬 2 notes", "🛠️ 1 tool call"); + expectFreshFinalText("done"); }); it("does not update Discord progress drafts after final answer delivery", async () => { @@ -2161,8 +2236,8 @@ describe("processDiscordMessage draft streaming", () => { const updates = draftStream.update.mock.calls.map((call) => call[0]); expect(updates).toEqual(["Shelling\n\n🛠️ Exec\n• exec running"]); - expectPreviewEditContent("done"); - expect(deliverDiscordReply).not.toHaveBeenCalled(); + expectProgressSummaryEdit("🛠️ 1 tool call"); + expectFreshFinalText("done"); }); it("does not update Discord progress drafts while final answer delivery is pending", async () => { @@ -2193,16 +2268,16 @@ describe("processDiscordMessage draft streaming", () => { const updates = draftStream.update.mock.calls.map((call) => call[0]); expect(updates).toEqual(["Shelling\n\n🛠️ Exec\n• exec running"]); - expectPreviewEditContent("done"); - expect(deliverDiscordReply).not.toHaveBeenCalled(); + expectProgressSummaryEdit("🛠️ 1 tool call"); + expectFreshFinalText("done"); }); - it("keeps Discord tool progress private for coding-profile message-tool-only guild replies", async () => { + it("streams Discord tool progress for coding-profile message-tool-only guild replies", async () => { + const draftStream = createMockDraftStreamForTest(); + dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => { expect(params?.replyOptions?.sourceReplyDeliveryMode).toBe("message_tool_only"); - expect( - params?.replyOptions?.allowProgressCallbacksWhenSourceDeliverySuppressed, - ).toBeUndefined(); + expect(params?.replyOptions?.allowProgressCallbacksWhenSourceDeliverySuppressed).toBe(true); await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" }); await params?.replyOptions?.onItemEvent?.({ progressText: "exec done" }); return createNoQueuedDispatchResult(); @@ -2222,36 +2297,7 @@ describe("processDiscordMessage draft streaming", () => { await runProcessDiscordMessage(ctx); expect(getLastDispatchReplyOptions()?.sourceReplyDeliveryMode).toBe("message_tool_only"); - expect(createDiscordDraftStream).not.toHaveBeenCalled(); - expect(deliverDiscordReply).not.toHaveBeenCalled(); - }); - - it("preserves explicitly enabled status reactions without exposing tool progress drafts", async () => { - dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => { - expect(params?.replyOptions?.sourceReplyDeliveryMode).toBe("message_tool_only"); - expect(params?.replyOptions?.allowProgressCallbacksWhenSourceDeliverySuppressed).toBe(true); - expect(params?.replyOptions?.suppressDefaultToolProgressMessages).toBe(true); - await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" }); - return createNoQueuedDispatchResult(); - }); - - const ctx = await createBaseContext({ - cfg: { - tools: { profile: "coding" }, - messages: { - ackReaction: "👀", - groupChat: { visibleReplies: "message_tool" }, - statusReactions: { enabled: true, timing: { debounceMs: 0 } }, - }, - session: { store: "/tmp/openclaw-discord-process-test-sessions.json" }, - }, - route: BASE_CHANNEL_ROUTE, - }); - - await runProcessDiscordMessage(ctx); - - expect(getReactionEmojis()).toContain(DEFAULT_EMOJIS.done); - expect(createDiscordDraftStream).not.toHaveBeenCalled(); + expect(draftStream.update).toHaveBeenCalledWith("Pinching\n\n🛠️ Exec\n• exec done"); expect(deliverDiscordReply).not.toHaveBeenCalled(); }); @@ -2300,12 +2346,9 @@ describe("processDiscordMessage draft streaming", () => { await runProcessDiscordMessage(ctx); expect(draftStream.update).toHaveBeenCalledTimes(1); - expect(editMessageDiscord).not.toHaveBeenCalled(); + expectProgressSummaryEdit("🛠️ 1 tool call"); expect(deliverDiscordReply).toHaveBeenCalledTimes(1); - const params = firstMockArg(deliverDiscordReply, "deliverDiscordReply"); - const replies = requireRecord(params, "deliverDiscordReply params").replies; - expect(Array.isArray(replies)).toBe(true); - expect((replies as Array<{ text?: string }>)[0]?.text).toBe(fullAnswer); + expectFreshFinalText(fullAnswer); }); it("clears partial drafts when fallback final delivery fails before completion", async () => { @@ -2644,20 +2687,20 @@ describe("processDiscordMessage draft streaming", () => { expect(deliverDiscordReply).not.toHaveBeenCalled(); }); - it("delivers reasoning block payloads to Discord", async () => { + it("renders reasoning block payloads as a 🧠 blockquote", async () => { mockDispatchSingleBlockReply({ text: "thinking...", isReasoning: true }); await processStreamOffDiscordMessage(); expect(deliverDiscordReply).toHaveBeenCalledTimes(1); expect(firstMockArg(deliverDiscordReply, "deliverDiscordReply")).toMatchObject({ - replies: [{ text: "thinking...", isReasoning: true }], + replies: [{ text: "> 🧠 thinking..." }], }); }); - it("delivers reasoning-tagged final payload to Discord", async () => { + it("renders reasoning-tagged final payloads as a 🧠 blockquote, never the final", async () => { dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => { await params?.dispatcher.sendFinalReply({ - text: "Reasoning:\nthis should be visible", + text: "Reasoning:\nthis renders as a quoted thinking message", isReasoning: true, }); return { queuedFinal: true, counts: { final: 1, tool: 0, block: 0 } }; @@ -2671,7 +2714,7 @@ describe("processDiscordMessage draft streaming", () => { expect(deliverDiscordReply).toHaveBeenCalledTimes(1); expect(firstMockArg(deliverDiscordReply, "deliverDiscordReply")).toMatchObject({ - replies: [{ text: "this should be visible", isReasoning: true }], + replies: [{ text: "> 🧠 this renders as a quoted thinking message" }], }); }); @@ -2748,7 +2791,7 @@ describe("processDiscordMessage draft streaming", () => { }); it.each([ - ["unset", undefined], + // commentary now defaults on; only an explicit false hides it. ["false", false], ])("hides Discord commentary progress when commentary is %s", async (_label, commentary) => { const draftStream = createMockDraftStreamForTest(); @@ -2852,7 +2895,7 @@ describe("processDiscordMessage draft streaming", () => { await runProcessDiscordMessage(ctx); expect(draftStream.update).toHaveBeenLastCalledWith( - "_Checking the current weather source before summarizing clearly._", + "💬 Checking the current weather source before summarizing clearly.", ); const updates = draftStream.update.mock.calls.map((call) => call[0]).join("\n"); expect(updates).not.toContain("Exec"); @@ -2905,6 +2948,46 @@ describe("processDiscordMessage draft streaming", () => { }, ); + it.each([ + ["active", true], + ["inactive", false], + ])( + "renders Discord tool lines in the draft exactly when durable verbose progress is %s", + async (_label, durableLaneActive) => { + const draftStream = createMockDraftStreamForTest(); + + dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => { + params?.replyOptions?.onVerboseProgressVisibility?.(() => durableLaneActive); + await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" }); + await params?.replyOptions?.onItemEvent?.({ progressText: "exec running" }); + await params?.replyOptions?.onCommandOutput?.({ + phase: "end", + title: "Exec", + name: "exec", + exitCode: 0, + }); + return createNoQueuedDispatchResult(); + }); + + const ctx = await createAutomaticSourceDeliveryContext({ + discordConfig: { + streaming: { mode: "progress", progress: { label: "Shelling" } }, + }, + }); + + await runProcessDiscordMessage(ctx); + + const updates = draftStream.update.mock.calls.map((call) => call[0]).join("\n"); + if (durableLaneActive) { + // The durable verbose lane persists tool summaries: the ephemeral + // draft must not render the same tool activity a second time. + expect(updates).toBe(""); + } else { + expect(updates).toContain("Exec"); + } + }, + ); + it("keeps Discord progress drafts usable after the last commentary line becomes silent", async () => { const draftStream = createMockDraftStreamForTest(); @@ -2976,9 +3059,9 @@ describe("processDiscordMessage draft streaming", () => { await runProcessDiscordMessage(ctx); const updates = draftStream.update.mock.calls.map((call) => call[0]); - expect(updates).toEqual(["_Checking source data._"]); - expectPreviewEditContent("done"); - expect(deliverDiscordReply).not.toHaveBeenCalled(); + expect(updates).toEqual(["💬 Checking source data."]); + expectProgressSummaryEdit(); + expectFreshFinalText("done"); }); it("does not start Discord progress drafts for text-only accepted turns", async () => { @@ -3028,47 +3111,8 @@ describe("processDiscordMessage draft streaming", () => { await runProcessDiscordMessage(ctx); expect(draftStream.update).toHaveBeenCalledWith("Shelling\n\n🛠️ Exec\n• exec done"); - expect(deliverDiscordReply).not.toHaveBeenCalled(); - expectPreviewEditContent("done"); - }); - - it("replaces Discord command progress items with matching command output", async () => { - const draftStream = createMockDraftStreamForTest(); - - dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => { - await params?.replyOptions?.onItemEvent?.({ - itemId: "tool:call-1", - toolCallId: "call-1", - kind: "command", - name: "exec", - progressText: "install dependencies", - }); - await params?.replyOptions?.onCommandOutput?.({ - itemId: "tool:call-1-output", - toolCallId: "call-1", - phase: "end", - name: "exec", - exitCode: 0, - }); - return createNoQueuedDispatchResult(); - }); - - const ctx = await createAutomaticSourceDeliveryContext({ - discordConfig: { - streaming: { - mode: "progress", - progress: { - label: "Shelling", - }, - }, - }, - }); - - await runProcessDiscordMessage(ctx); - - const lastUpdate = draftStream.update.mock.calls.at(-1)?.[0]; - expect(lastUpdate).toContain("install dependencies"); - expect(lastUpdate).not.toContain("completed"); + expectProgressSummaryEdit("🛠️ 1 tool call"); + expectFreshFinalText("done"); }); it("drops later tool warning finals after progress preview final replies", async () => { @@ -3097,10 +3141,11 @@ describe("processDiscordMessage draft streaming", () => { await runProcessDiscordMessage(ctx); expect(draftStream.update).toHaveBeenCalledWith("Shelling\n\n🛠️ Exec\n• exec done"); - expectPreviewEditContent("delivery survived"); + expectProgressSummaryEdit("🛠️ 1 tool call"); expect(draftStream.clear).not.toHaveBeenCalled(); expect(draftStream.messageId()).toBe("preview-1"); - expect(deliverDiscordReply).not.toHaveBeenCalled(); + expect(deliverDiscordReply).toHaveBeenCalledTimes(1); + expectFreshFinalText("delivery survived"); }); it("uses raw tool-progress detail in Discord progress drafts", async () => { @@ -3255,13 +3300,45 @@ describe("processDiscordMessage draft streaming", () => { await runProcessDiscordMessage(ctx); expect(draftStream.update).toHaveBeenCalledWith( - "Clawing...\n\n🛠️ Exec\n• _Reading the event projector_", + "Clawing...\n\n🛠️ Exec\n🧠 _Reading the event projector_", ); const updates = draftStream.update.mock.calls.map((call) => call[0]); expect(updates.join("\n")).not.toContain("Reasoning"); expect(updates.join("\n")).not.toContain("Thinking\n"); }); + it("hides non-stream reasoning progress until Discord thinking progress is enabled", async () => { + const draftStream = createMockDraftStreamForTest(); + + dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => { + await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" }); + await params?.replyOptions?.onReasoningStream?.({ + text: "Private planning", + requiresReasoningProgressOptIn: true, + }); + await params?.replyOptions?.onItemEvent?.({ progressText: "done" }); + return createNoQueuedDispatchResult(); + }); + + const ctx = await createAutomaticSourceDeliveryContext({ + discordConfig: { + streaming: { + mode: "progress", + progress: { + label: "Clawing...", + }, + }, + }, + }); + + await runProcessDiscordMessage(ctx); + + expect(draftStream.update).toHaveBeenCalledWith("Clawing...\n\n🛠️ Exec\n• done"); + expect(draftStream.update.mock.calls.map((call) => call[0]).join("\n")).not.toContain( + "Private planning", + ); + }); + it("accumulates reasoning deltas in Discord progress drafts", async () => { const draftStream = createMockDraftStreamForTest(); @@ -3279,6 +3356,7 @@ describe("processDiscordMessage draft streaming", () => { mode: "progress", progress: { label: "Clawing...", + thinking: true, }, }, }, @@ -3287,7 +3365,7 @@ describe("processDiscordMessage draft streaming", () => { await runProcessDiscordMessage(ctx); expect(draftStream.update).toHaveBeenCalledWith( - "Clawing...\n\n🛠️ Exec\n• _Considering plugin installation!_", + "Clawing...\n\n🛠️ Exec\n🧠 _Considering plugin installation!_", ); const updates = draftStream.update.mock.calls.map((call) => call[0]); expect(updates.join("\n")).not.toContain("• _!_"); @@ -3309,6 +3387,7 @@ describe("processDiscordMessage draft streaming", () => { mode: "progress", progress: { label: "Clawing...", + thinking: true, }, }, }, @@ -3317,7 +3396,7 @@ describe("processDiscordMessage draft streaming", () => { await runProcessDiscordMessage(ctx); expect(draftStream.update).toHaveBeenCalledWith( - "Clawing...\n\n🛠️ Exec\n• _Thinking through the install plan_", + "Clawing...\n\n🛠️ Exec\n🧠 _Thinking through the install plan_", ); }); @@ -3336,6 +3415,7 @@ describe("processDiscordMessage draft streaming", () => { mode: "progress", progress: { label: "Clawing...", + thinking: true, }, }, }, @@ -3344,7 +3424,7 @@ describe("processDiscordMessage draft streaming", () => { await runProcessDiscordMessage(ctx); expect(draftStream.update).toHaveBeenCalledWith( - "Clawing...\n\n🛠️ Exec\n• _Thinking: compare install paths_", + "Clawing...\n\n🛠️ Exec\n🧠 _Thinking: compare install paths_", ); }); @@ -3363,6 +3443,7 @@ describe("processDiscordMessage draft streaming", () => { mode: "progress", progress: { label: "Clawing...", + thinking: true, }, }, }, @@ -3371,7 +3452,7 @@ describe("processDiscordMessage draft streaming", () => { await runProcessDiscordMessage(ctx); expect(draftStream.update).toHaveBeenCalledWith( - "Clawing...\n\n🛠️ Exec\n• _Reasoning: compare install paths_", + "Clawing...\n\n🛠️ Exec\n🧠 _Reasoning: compare install paths_", ); }); @@ -3392,6 +3473,7 @@ describe("processDiscordMessage draft streaming", () => { mode: "progress", progress: { label: "Clawing...", + thinking: true, }, }, }, @@ -3400,7 +3482,7 @@ describe("processDiscordMessage draft streaming", () => { await runProcessDiscordMessage(ctx); expect(draftStream.update).toHaveBeenCalledWith( - "Clawing...\n\n🛠️ Exec\n• _compare install paths_", + "Clawing...\n\n🛠️ Exec\n🧠 _compare install paths_", ); }); @@ -3421,6 +3503,7 @@ describe("processDiscordMessage draft streaming", () => { mode: "progress", progress: { label: "Clawing...", + thinking: true, }, }, }, @@ -3429,7 +3512,7 @@ describe("processDiscordMessage draft streaming", () => { await runProcessDiscordMessage(ctx); expect(draftStream.update).toHaveBeenCalledWith( - "Clawing...\n\n🛠️ Exec\n• _compare install paths_", + "Clawing...\n\n🛠️ Exec\n🧠 _compare install paths_", ); }); @@ -3448,6 +3531,7 @@ describe("processDiscordMessage draft streaming", () => { mode: "progress", progress: { label: "Clawing...", + thinking: true, }, }, }, @@ -3456,7 +3540,7 @@ describe("processDiscordMessage draft streaming", () => { await runProcessDiscordMessage(ctx); expect(draftStream.update).toHaveBeenCalledWith( - "Clawing...\n\n🛠️ Exec\n• _Thinking through the plan_", + "Clawing...\n\n🛠️ Exec\n🧠 _Thinking through the plan_", ); }); @@ -3476,6 +3560,7 @@ describe("processDiscordMessage draft streaming", () => { mode: "progress", progress: { label: "Clawing...", + thinking: true, }, }, }, @@ -3484,7 +3569,7 @@ describe("processDiscordMessage draft streaming", () => { await runProcessDiscordMessage(ctx); expect(draftStream.update).toHaveBeenCalledWith( - "Clawing...\n\n🛠️ Exec\n• _I was Thinking about the plan_", + "Clawing...\n\n🛠️ Exec\n🧠 _I was Thinking about the plan_", ); }); @@ -3504,6 +3589,7 @@ describe("processDiscordMessage draft streaming", () => { mode: "progress", progress: { label: "Clawing...", + thinking: true, }, }, }, @@ -3512,7 +3598,7 @@ describe("processDiscordMessage draft streaming", () => { await runProcessDiscordMessage(ctx); expect(draftStream.update).toHaveBeenCalledWith( - "Clawing...\n\n🛠️ Exec\n• _I was Thinking... through the plan_", + "Clawing...\n\n🛠️ Exec\n🧠 _I was Thinking... through the plan_", ); }); @@ -3532,6 +3618,7 @@ describe("processDiscordMessage draft streaming", () => { mode: "progress", progress: { label: "Clawing...", + thinking: true, }, }, }, @@ -3540,7 +3627,7 @@ describe("processDiscordMessage draft streaming", () => { await runProcessDiscordMessage(ctx); expect(draftStream.update).toHaveBeenCalledWith( - "Clawing...\n\n🛠️ Exec\n• _I was Reasoning: through edge cases_", + "Clawing...\n\n🛠️ Exec\n🧠 _I was Reasoning: through edge cases_", ); }); @@ -3562,6 +3649,7 @@ describe("processDiscordMessage draft streaming", () => { progress: { label: "Clawing...", maxLineChars: 36, + thinking: true, }, }, }, @@ -3572,7 +3660,7 @@ describe("processDiscordMessage draft streaming", () => { const lastUpdate = draftStream.update.mock.calls.at(-1)?.[0]; const reasoningLine = lastUpdate?.split("\n").at(-1); - expect(reasoningLine).toMatch(/^• _.*…_$/u); + expect(reasoningLine).toMatch(/^🧠 _.*…_$/u); expect(reasoningLine?.match(/_/gu)).toHaveLength(2); }); @@ -3598,6 +3686,7 @@ describe("processDiscordMessage draft streaming", () => { mode: "progress", progress: { label: "Clawing...", + thinking: true, }, }, }, diff --git a/extensions/discord/src/monitor/message-handler.process.ts b/extensions/discord/src/monitor/message-handler.process.ts index 6786cef48df3..a08d766ef281 100644 --- a/extensions/discord/src/monitor/message-handler.process.ts +++ b/extensions/discord/src/monitor/message-handler.process.ts @@ -21,6 +21,7 @@ import { import { buildChannelProgressDraftLine, buildChannelProgressDraftLineForEntry, + isChannelProgressDraftWorkToolName, resolveChannelStreamingBlockEnabled, resolveTranscriptBackedChannelFinalText, } from "openclaw/plugin-sdk/channel-outbound"; @@ -40,6 +41,7 @@ import { danger, logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtim import { getSessionEntry, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime"; import { readLatestAssistantTextByIdentity } from "openclaw/plugin-sdk/session-transcript-runtime"; import { resolveDiscordAccount, resolveDiscordMaxLinesPerMessage } from "../accounts.js"; +import { chunkDiscordTextWithMode } from "../chunk.js"; import { createDiscordRestClient } from "../client.js"; import { beginDiscordInboundEventDeliveryCorrelation } from "../inbound-event-delivery.js"; import { @@ -566,10 +568,7 @@ async function processDiscordMessageInner( chunkMode, log: logVerbose, }); - // While the durable verbose commentary lane is active (dispatch reports it - // via onVerboseProgressVisibility), the ephemeral draft yields its commentary - // lines so commentary is not rendered in both lanes. - let verboseProgressActive: () => boolean = () => false; + let shouldYieldDraftProgress: () => boolean = () => false; const finalPreviewFlags = (discordConfig?.suppressEmbeds ?? true) ? MessageFlags.SuppressEmbeds : undefined; let finalReplyStartNotified = false; @@ -593,6 +592,100 @@ async function processDiscordMessageInner( draftPreview.markFinalReplyDelivered(); observer?.onFinalReplyDelivered?.(); }; + // Per-line quoting survives Discord chunking; blank quote rows render badly. + const formatDiscordReasoningQuote = (quoteText: string): string | undefined => { + const lines = quoteText + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + if (!lines.length) { + return undefined; + } + lines[0] = `🧠 ${lines[0]}`; + return lines.map((line) => `> ${line}`).join("\n"); + }; + // Reasoning delivery follows the session /reasoning level, not streaming config. + const reasoningLevel = ((): "on" | "stream" | "off" => { + const normalizedAgentId = (route.agentId ?? "").trim().toLowerCase() || "main"; + const agentEntryDefault = cfg.agents?.list?.find( + (entry) => ((entry?.id ?? "").trim().toLowerCase() || "main") === normalizedAgentId, + )?.reasoningDefault; + const cfgDefault = agentEntryDefault ?? cfg.agents?.defaults?.reasoningDefault; + const configDefault: "on" | "stream" | "off" = + cfgDefault === "on" || cfgDefault === "stream" ? cfgDefault : "off"; + const sessionKey = ctxPayload.SessionKey; + if (!sessionKey) { + return configDefault; + } + try { + const storePath = resolveStorePath(cfg.session?.store, { agentId: route.agentId }); + const level = getSessionEntry({ + agentId: route.agentId, + sessionKey, + storePath, + })?.reasoningLevel; + if (level === "on" || level === "stream" || level === "off") { + return level; + } + } catch { + return "off"; + } + return configDefault; + })(); + const reasoningDurableEnabled = reasoningLevel === "on"; + const reasoningWindowEnabled = reasoningLevel === "stream"; + const progressTurnStartedAt = Date.now(); + let progressReasoningSteps = 0; + let progressToolCalls = 0; + let progressCommentaryNotes = 0; + // Durable reasoning posts after the draft; summary must land below it. + let persistentReasoningDelivered = false; + // Preamble updates can re-fire; count each item id or id-less text once. + const seenCommentaryIds = new Set(); + let lastCommentaryNoteText = ""; + const noteWindowCommentary = (itemId?: string, noteText?: string) => { + const trimmed = noteText?.trim(); + if (!trimmed) { + return; + } + if (itemId) { + if (seenCommentaryIds.has(itemId)) { + return; + } + seenCommentaryIds.add(itemId); + progressCommentaryNotes += 1; + return; + } + if (trimmed !== lastCommentaryNoteText) { + lastCommentaryNoteText = trimmed; + progressCommentaryNotes += 1; + } + }; + // DeepSeek does not always emit a thinking_end, so tool/final boundaries also close bursts. + let windowReasoningOpen = false; + const closePendingWindowThought = () => { + if (windowReasoningOpen) { + windowReasoningOpen = false; + progressReasoningSteps += 1; + } + }; + const buildProgressSummaryLine = () => { + closePendingWindowThought(); + const seconds = Math.max(1, Math.round((Date.now() - progressTurnStartedAt) / 1000)); + const parts = [ + ...(progressReasoningSteps > 0 + ? [`🧠 ${progressReasoningSteps} thought${progressReasoningSteps === 1 ? "" : "s"}`] + : []), + ...(progressCommentaryNotes > 0 + ? [`💬 ${progressCommentaryNotes} note${progressCommentaryNotes === 1 ? "" : "s"}`] + : []), + ...(progressToolCalls > 0 + ? [`🛠️ ${progressToolCalls} tool call${progressToolCalls === 1 ? "" : "s"}`] + : []), + `⏱️ ${seconds}s`, + ]; + return `-# ${parts.join(" · ")}`; + }; const beforeDiscordPayloadDelivery = ( payload: ReplyPayload, info: { kind: ReplyDispatchKind }, @@ -608,6 +701,9 @@ async function processDiscordMessageInner( ); return null; } + if (payload.isReasoning || payload.isCommentary) { + return payload; + } if (draftPreview.draftStream && draftPreview.isProgressMode && info.kind === "block") { const reply = resolveSendableOutboundReplyParts(payload); if (!reply.hasMedia && !payload.isError) { @@ -639,6 +735,50 @@ async function processDiscordMessageInner( return { visibleReplySent: false }; } const isFinal = info.kind === "final"; + if (payload.isReasoning) { + const raw = (payload.text ?? "").trim(); + const body = raw.startsWith("Reasoning:\n") ? raw.slice("Reasoning:\n".length).trim() : raw; + if (!body) { + return { visibleReplySent: false }; + } + const chunkLimit = Math.max(256, Math.min(textLimit, 2000) - 8); + const chunks = chunkDiscordTextWithMode(body, { + maxChars: chunkLimit, + maxLines: maxLinesPerMessage, + chunkMode, + }); + const replies = (chunks.length ? chunks : [body]) + .map((chunk) => formatDiscordReasoningQuote(chunk)) + .filter((quote): quote is string => Boolean(quote)) + .map((quote) => Object.assign({}, payload, { text: quote, isReasoning: undefined })); + if (!replies.length) { + return { visibleReplySent: false }; + } + await deliverDiscordReply({ + cfg, + replies, + target: deliverTarget, + token, + accountId, + rest: deliveryRest, + runtime, + replyToId: replyReference.use(), + replyToMode, + textLimit, + maxLinesPerMessage, + tableMode, + chunkMode, + sessionKey: ctxPayload.SessionKey, + threadBindings, + mediaLocalRoots, + kind: "block", + }); + replyReference.markSent(); + // Durable 🧠 (/reasoning on) is persisted, not streamed — never count it in the + // bar. Mark that durable reasoning posted so the collapse anchors below it. + persistentReasoningDelivered = true; + return { visibleReplySent: true }; + } if ( isFinal && !options?.allowFallbackOnlyToolWarning && @@ -684,11 +824,65 @@ async function processDiscordMessageInner( return { visibleReplySent: false }; } } - const shouldFinalizeDraftPreview = + const shouldCollapseProgressDraft = draftStream && isFinal && - (!draftPreview.isProgressMode || draftPreview.hasProgressDraftStarted) && + draftPreview.isProgressMode && + draftPreview.hasProgressDraftStarted && !deliverablePayload.isError; + if (shouldCollapseProgressDraft && draftStream) { + await draftPreview.flush(); + if (persistentReasoningDelivered) { + // Keep /reasoning on order as thoughts, summary, answer. + await draftStream.clear(); + await deliverDiscordReply({ + cfg, + replies: [{ text: buildProgressSummaryLine() }], + target: deliverTarget, + token, + accountId, + rest: deliveryRest, + runtime, + replyToId: replyReference.use(), + replyToMode, + textLimit, + maxLinesPerMessage, + tableMode, + chunkMode, + sessionKey: ctxPayload.SessionKey, + threadBindings, + mediaLocalRoots, + kind: "block", + }); + replyReference.markSent(); + draftPreview.markPreviewFinalized(); + } else { + const draftId = draftStream.messageId(); + if (draftId !== undefined) { + await draftStream.seal(); + try { + await editMessageDiscord( + deliverChannelId, + draftId, + { + content: buildProgressSummaryLine(), + ...(finalPreviewFlags ? { flags: finalPreviewFlags } : {}), + }, + { cfg, accountId, rest: deliveryRest }, + ); + draftPreview.markPreviewFinalized(); + } catch (err) { + logVerbose( + `discord: progress draft summary edit failed; clearing draft (${String(err)})`, + ); + await draftStream.clear(); + } + } + } + // Fall through to the generic fresh send below for the final itself. + } + const shouldFinalizeDraftPreview = + draftStream && isFinal && !draftPreview.isProgressMode && !deliverablePayload.isError; if (shouldFinalizeDraftPreview) { const reply = resolveSendableOutboundReplyParts(deliverablePayload); const hasMedia = reply.hasMedia; @@ -968,7 +1162,9 @@ async function processDiscordMessageInner( queuedDeliveryCorrelations: isRoomEvent ? [{ begin: beginDeliveryCorrelation }] : undefined, suppressTyping: isRoomEvent ? true : undefined, allowProgressCallbacksWhenSourceDeliverySuppressed: - sourceRepliesAreToolOnly && statusReactionsExplicitlyEnabled ? true : undefined, + sourceRepliesAreToolOnly && draftPreview.draftStream && draftPreview.isProgressMode + ? true + : undefined, disableBlockStreaming: sourceRepliesAreToolOnly ? true : (draftPreview.disableBlockStreamingForDraft ?? @@ -983,7 +1179,10 @@ async function processDiscordMessageInner( ? () => draftPreview.handleAssistantMessageBoundary() : undefined, onReasoningEnd: draftPreview.draftStream - ? () => draftPreview.handleAssistantMessageBoundary() + ? () => { + closePendingWindowThought(); + return draftPreview.handleAssistantMessageBoundary(); + } : undefined, onModelSelected, suppressDefaultToolProgressMessages: @@ -995,21 +1194,42 @@ async function processDiscordMessageInner( commentaryProgressEnabled: draftPreview.isProgressMode ? draftPreview.commentaryProgressEnabled : undefined, + commentaryPayloadsEnabled: draftPreview.isProgressMode + ? draftPreview.commentaryProgressEnabled + : undefined, + reasoningPayloadsEnabled: reasoningDurableEnabled, onVerboseProgressVisibility: (isActive) => { - verboseProgressActive = isActive; + shouldYieldDraftProgress = isActive; }, onReasoningStream: async (payload) => { + if (payload?.requiresReasoningProgressOptIn === true && !reasoningWindowEnabled) { + return; + } + if (payload?.text) { + windowReasoningOpen = true; + } await statusReactions.setThinking(); await draftPreview.pushReasoningProgress(payload?.text, { snapshot: payload?.isReasoningSnapshot === true, }); }, + streamReasoningInNonStreamModes: reasoningWindowEnabled, onToolStart: async (payload) => { if (isProcessAborted(abortSignal)) { return; } await maybeBindStatusReactionsToToolReaction(payload); await statusReactions.setTool(payload.name); + if (payload.phase === "start") { + closePendingWindowThought(); + } + if (shouldYieldDraftProgress()) { + return; + } + // Match the compositor: message/react/typing are not work-tool lines. + if (payload.phase === "start" && isChannelProgressDraftWorkToolName(payload.name)) { + progressToolCalls += 1; + } await draftPreview.pushToolProgress( buildChannelProgressDraftLineForEntry( discordConfig, @@ -1028,16 +1248,21 @@ async function processDiscordMessageInner( }, onItemEvent: async (payload) => { if (payload.kind === "preamble") { - if (verboseProgressActive()) { + if (shouldYieldDraftProgress()) { return; } if (draftPreview.commentaryProgressEnabled && payload.progressText) { + // Count only commentary that actually streams to the window draft. + noteWindowCommentary(payload.itemId, payload.progressText); await draftPreview.pushCommentaryProgress(payload.progressText, { itemId: payload.itemId, }); } return; } + if (shouldYieldDraftProgress()) { + return; + } await draftPreview.pushToolProgress( buildChannelProgressDraftLineForEntry(discordConfig, { event: "item", @@ -1087,6 +1312,9 @@ async function processDiscordMessageInner( if (payload.phase !== "end") { return; } + if (shouldYieldDraftProgress()) { + return; + } await draftPreview.pushToolProgress( buildChannelProgressDraftLine({ event: "command-output", @@ -1104,6 +1332,9 @@ async function processDiscordMessageInner( if (payload.phase !== "end") { return; } + if (shouldYieldDraftProgress()) { + return; + } await draftPreview.pushToolProgress( buildChannelProgressDraftLine({ event: "patch", diff --git a/extensions/telegram/src/bot-message-dispatch.test.ts b/extensions/telegram/src/bot-message-dispatch.test.ts index ee6f4b7a0e85..bb354dba9249 100644 --- a/extensions/telegram/src/bot-message-dispatch.test.ts +++ b/extensions/telegram/src/bot-message-dispatch.test.ts @@ -3259,6 +3259,36 @@ describe("dispatchTelegramMessage draft streaming", () => { expect(draftStream.flush).toHaveBeenCalled(); }); + it("keeps streamed reasoning visible when tool progress lines are hidden", async () => { + const draftStream = createSequencedDraftStream(2001); + createTelegramDraftStream.mockReturnValue(draftStream); + dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ replyOptions }) => { + await replyOptions?.onReplyStart?.(); + await replyOptions?.onAssistantMessageStart?.(); + await replyOptions?.onToolStart?.({ name: "exec", phase: "start" }); + await replyOptions?.onReasoningStream?.({ text: "Checking files" }); + return { queuedFinal: false }; + }); + + await dispatchWithContext({ + context: createReasoningStreamContext(), + streamMode: "progress", + telegramCfg: { + streaming: { + mode: "progress", + progress: { label: "Shelling", toolProgress: false }, + }, + }, + }); + + expect(draftStream.updatePreview).toHaveBeenCalledWith( + telegramProgressPreview( + "Shelling\n\n• Checking files", + "Shelling\nChecking files", + ), + ); + }); + it.each([{ label: false }, { label: "Shelling", maxLines: 1 }] as const)( "does not duplicate Telegram progress HTML rows without a visible label", async (progress) => { diff --git a/extensions/telegram/src/bot-message-dispatch.ts b/extensions/telegram/src/bot-message-dispatch.ts index 87fe5d852c20..2dc8ca4c8de2 100644 --- a/extensions/telegram/src/bot-message-dispatch.ts +++ b/extensions/telegram/src/bot-message-dispatch.ts @@ -1046,6 +1046,7 @@ export const dispatchTelegramMessage = async ({ active: Boolean(answerLane.stream), seed: progressSeed, formatLine: formatTelegramProgressLine, + reasoningGate: streamReasoningInProgressDraft, update: async (streamText, options) => { await prepareAnswerLaneForToolProgress(); answerLane.lastPartialText = streamText; diff --git a/qa/scenarios/models/gpt55-thinking-visibility-switch.yaml b/qa/scenarios/models/gpt55-thinking-visibility-switch.yaml index b618af9da98a..6e879d656dd8 100644 --- a/qa/scenarios/models/gpt55-thinking-visibility-switch.yaml +++ b/qa/scenarios/models/gpt55-thinking-visibility-switch.yaml @@ -122,7 +122,7 @@ flow: message: expr: "`missing off marker; saw ${offMessages.map((message) => message.text).join(' | ')}`" - assert: - expr: "!offMessages.some((candidate) => candidate.text.trimStart().startsWith('Reasoning:') && !candidate.text.includes('Native reasoning was produced; no summary text was returned.'))" + expr: "!offMessages.some((candidate) => candidate.text.trimStart().startsWith('Reasoning:'))" message: expr: "`disabled thinking unexpectedly emitted reasoning: ${offMessages.map((message) => message.text).join(' | ')}`" - if: diff --git a/scripts/plugin-sdk-surface-report.mjs b/scripts/plugin-sdk-surface-report.mjs index 42bb407729d3..b4f629ef92f3 100644 --- a/scripts/plugin-sdk-surface-report.mjs +++ b/scripts/plugin-sdk-surface-report.mjs @@ -112,11 +112,11 @@ const defaultPublicDeprecatedExportsByEntrypointBudget = Object.freeze({ "runtime-logger": 3, "runtime-secret-resolution": 5, "setup-adapter-runtime": 1, - "channel-streaming": 47, + "channel-streaming": 48, "approval-reply-runtime": 1, "config-runtime": 123, "config-contracts": 1, - "config-types": 420, + "config-types": 421, "config-schema": 3, "reply-dedupe": 1, "inbound-reply-dispatch": 33, @@ -158,8 +158,8 @@ const defaultPublicDeprecatedExportsByEntrypointBudget = Object.freeze({ "channel-mention-gating": 7, "channel-lifecycle": 23, "channel-ingress": 8, - "channel-message": 228, - "channel-message-runtime": 225, + "channel-message": 229, + "channel-message-runtime": 226, "channel-pairing-paths": 1, "channel-policy": 8, "channel-route": 5, @@ -202,11 +202,11 @@ let publicDeprecatedExportsByEntrypointBudget; try { budgets = { publicEntrypoints: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_ENTRYPOINTS", 322), - publicExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", 10400), - publicFunctionExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", 5219), + publicExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", 10401), + publicFunctionExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", 5220), publicDeprecatedExports: readBudgetEnv( "OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_DEPRECATED_EXPORTS", - 3256, + 3260, ), publicWildcardReexports: readBudgetEnv( "OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_WILDCARD_REEXPORTS", diff --git a/src/agents/anthropic-transport-stream.test.ts b/src/agents/anthropic-transport-stream.test.ts index f0e5531bb1a2..48d69176a065 100644 --- a/src/agents/anthropic-transport-stream.test.ts +++ b/src/agents/anthropic-transport-stream.test.ts @@ -200,6 +200,64 @@ describe("anthropic transport stream", () => { vi.useRealTimers(); }); + it("tags pre-tool narration as commentary when a proxy mislabels stop_reason (pioneer/Bedrock)", async () => { + // Bedrock/Vertex-proxied routes (e.g. pioneer; tool ids "toolu_vrtx_…") report + // stop_reason "end_turn" on turns that DO carry a tool call. Commentary tagging + // must key on the turn CONTAINING a toolCall, not on the stop_reason label, or + // the narration text stays untagged (textSignature=None) and never reaches the + // 💬 lane — exactly the pioneer commentary gap. + guardedFetchMock.mockResolvedValueOnce( + createSseResponse([ + { + type: "message_start", + message: { id: "msg_pio", usage: { input_tokens: 10, output_tokens: 0 } }, + }, + { + type: "content_block_start", + index: 0, + content_block: { type: "text", text: "I'll start by checking the current date." }, + }, + { type: "content_block_stop", index: 0 }, + { + type: "content_block_start", + index: 1, + content_block: { type: "tool_use", id: "toolu_vrtx_01S4", name: "exec", input: {} }, + }, + { + type: "content_block_delta", + index: 1, + delta: { type: "input_json_delta", partial_json: '{"command":"date"}' }, + }, + { type: "content_block_stop", index: 1 }, + { + // The proxy mislabel: a tool-using turn reported as end_turn, NOT tool_use. + type: "message_delta", + delta: { stop_reason: "end_turn" }, + usage: { input_tokens: 10, output_tokens: 7 }, + }, + ]), + ); + + const result = await runTransportStream( + makeAnthropicTransportModel(), + { + messages: [{ role: "user", content: "run date" }], + } as AnthropicStreamContext, + { + apiKey: "sk-ant-api", + } as AnthropicStreamOptions, + ); + + // Despite stop_reason=end_turn, the turn carries a toolCall, so the narration + // text must be tagged commentary (phase:commentary) and route to 💬. + const textBlock = findRecord(result.content, (record) => record.type === "text"); + expect(textBlock.textSignature).toBeDefined(); + expect(String(textBlock.textSignature)).toContain('"phase":"commentary"'); + expect(result.content.some((block) => (block as { type?: string }).type === "toolCall")).toBe( + true, + ); + }); + it("uses the guarded fetch transport for api-key Anthropic requests", async () => { const model = makeAnthropicTransportModel({ headers: { "X-Provider": "anthropic" }, @@ -787,6 +845,122 @@ describe("anthropic transport stream", () => { expect(result.errorMessage).toBe("Anthropic stream ended before message_stop"); }); + it("defers a pre-tool text block's text_end until it carries the commentary phase", async () => { + guardedFetchMock.mockResolvedValueOnce( + createSseResponse([ + { + type: "message_start", + message: { id: "msg_defer", usage: { input_tokens: 5, output_tokens: 0 } }, + }, + { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }, + { + type: "content_block_delta", + index: 0, + delta: { type: "text_delta", text: "I'll check the repo." }, + }, + { type: "content_block_stop", index: 0 }, + { + type: "content_block_start", + index: 1, + content_block: { type: "tool_use", id: "tool_1", name: "exec", input: {} }, + }, + { type: "content_block_stop", index: 1 }, + { + type: "message_delta", + delta: { stop_reason: "tool_use" }, + usage: { input_tokens: 5, output_tokens: 7 }, + }, + { type: "message_stop" }, + ]), + ); + const streamFn = createAnthropicMessagesTransportStreamFn(); + const stream = await Promise.resolve( + streamFn( + makeAnthropicTransportModel(), + { messages: [{ role: "user", content: "inspect" }] } as AnthropicStreamContext, + { apiKey: "sk-ant-api" } as AnthropicStreamOptions, + ), + ); + const order: string[] = []; + let textEndPhase: unknown; + for await (const event of stream as AsyncIterable<{ + type: string; + contentIndex?: number; + partial?: { content?: Array<{ textSignature?: string }> }; + }>) { + order.push(event.type); + if (event.type === "text_end" && typeof event.contentIndex === "number") { + const signature = event.partial?.content?.[event.contentIndex]?.textSignature; + textEndPhase = + typeof signature === "string" + ? (JSON.parse(signature) as { phase?: string }).phase + : undefined; + } + } + // The pre-tool text block's text_end is held until the tool boundary tags it + // commentary, so a block-reply consumer never durably commits the narration + // as the answer. It is still emitted (once) and still before the tool call. + expect(textEndPhase).toBe("commentary"); + expect(order.filter((type) => type === "text_end")).toHaveLength(1); + expect(order.indexOf("text_end")).toBeLessThan(order.indexOf("toolcall_start")); + }); + + it("emits a non-tool text block's text_end as unphased answer text", async () => { + guardedFetchMock.mockResolvedValueOnce( + createSseResponse([ + { + type: "message_start", + message: { id: "msg_answer", usage: { input_tokens: 5, output_tokens: 0 } }, + }, + { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }, + { + type: "content_block_delta", + index: 0, + delta: { type: "text_delta", text: "Here is the answer." }, + }, + { type: "content_block_stop", index: 0 }, + { + type: "message_delta", + delta: { stop_reason: "end_turn" }, + usage: { input_tokens: 5, output_tokens: 4 }, + }, + { type: "message_stop" }, + ]), + ); + const streamFn = createAnthropicMessagesTransportStreamFn(); + const stream = await Promise.resolve( + streamFn( + makeAnthropicTransportModel(), + { messages: [{ role: "user", content: "answer me" }] } as AnthropicStreamContext, + { apiKey: "sk-ant-api" } as AnthropicStreamOptions, + ), + ); + const order: string[] = []; + let textEndPhase: unknown = "unset"; + for await (const event of stream as AsyncIterable<{ + type: string; + contentIndex?: number; + partial?: { content?: Array<{ textSignature?: string }> }; + }>) { + order.push(event.type); + if (event.type === "text_end" && typeof event.contentIndex === "number") { + const signature = event.partial?.content?.[event.contentIndex]?.textSignature; + textEndPhase = + typeof signature === "string" + ? (JSON.parse(signature) as { phase?: string }).phase + : undefined; + } + } + const result = await stream.result(); + // No tool follows, so the held text_end is flushed unphased at message_delta + // and the text is delivered as the answer (never tagged commentary). + expect(order.filter((type) => type === "text_end")).toHaveLength(1); + expect(textEndPhase).toBeUndefined(); + const textBlock = findRecord(result.content, (record) => record.type === "text"); + expect(textBlock.text).toBe("Here is the answer."); + expect(textBlock.textSignature).toBeUndefined(); + }); + it("preserves unsafe integer Anthropic tool-use input deltas", async () => { guardedFetchMock.mockResolvedValueOnce( createSseResponse([ @@ -2479,7 +2653,7 @@ describe("anthropic transport stream", () => { ); const payload = latestAnthropicRequest().payload; - expect(payload.thinking).toEqual({ type: "adaptive" }); + expect(payload.thinking).toEqual({ type: "adaptive", display: "summarized" }); expect(payload.output_config).toEqual({ effort: "high" }); expect(payload.tool_choice).toBeUndefined(); }); @@ -2556,7 +2730,7 @@ describe("anthropic transport stream", () => { const payload = latestAnthropicRequest().payload; expect(payload.model).toBe("production-claude"); - expect(payload.thinking).toEqual({ type: "adaptive" }); + expect(payload.thinking).toEqual({ type: "adaptive", display: "summarized" }); expect(payload.output_config).toEqual({ effort: "xhigh" }); expect(payload).not.toHaveProperty("temperature"); }); @@ -2659,7 +2833,7 @@ describe("anthropic transport stream", () => { ); const payload = latestAnthropicRequest().payload; - expect(payload.thinking).toEqual({ type: "adaptive" }); + expect(payload.thinking).toEqual({ type: "adaptive", display: "summarized" }); expect(payload.output_config).toEqual({ effort: "high" }); }); @@ -2686,7 +2860,7 @@ describe("anthropic transport stream", () => { ); const payload = latestAnthropicRequest().payload; - expect(payload.thinking).toEqual({ type: "adaptive" }); + expect(payload.thinking).toEqual({ type: "adaptive", display: "summarized" }); expect(payload.output_config).toEqual({ effort: "high" }); }); @@ -2791,7 +2965,7 @@ describe("anthropic transport stream", () => { ); const payload = latestAnthropicRequest().payload; - expect(payload.thinking).toEqual({ type: "adaptive" }); + expect(payload.thinking).toEqual({ type: "adaptive", display: "summarized" }); expect(payload.output_config).toEqual({ effort: "xhigh" }); }); @@ -2815,7 +2989,7 @@ describe("anthropic transport stream", () => { ); const payload = latestAnthropicRequest().payload; - expect(payload.thinking).toEqual({ type: "adaptive" }); + expect(payload.thinking).toEqual({ type: "adaptive", display: "summarized" }); expect(payload.output_config).toEqual({ effort: "max" }); }); @@ -2840,7 +3014,7 @@ describe("anthropic transport stream", () => { ); const payload = latestAnthropicRequest().payload; - expect(payload.thinking).toEqual({ type: "adaptive" }); + expect(payload.thinking).toEqual({ type: "adaptive", display: "summarized" }); expect(payload.output_config).toEqual({ effort: "high" }); }); diff --git a/src/agents/anthropic-transport-stream.ts b/src/agents/anthropic-transport-stream.ts index 69f458b6b560..b7d0a0f872be 100644 --- a/src/agents/anthropic-transport-stream.ts +++ b/src/agents/anthropic-transport-stream.ts @@ -12,7 +12,7 @@ import { ANTHROPIC_OMITTED_REASONING_TEXT, findActiveAnthropicToolTurnAssistantIndex, } from "../llm/providers/anthropic-thinking-replay.js"; -import type { AnthropicOptions } from "../llm/providers/anthropic.js"; +import type { AnthropicOptions, AnthropicThinkingDisplay } from "../llm/providers/anthropic.js"; import type { AssistantMessageDiagnostic, Context, @@ -58,6 +58,7 @@ import { coerceTransportToolCallArguments, createEmptyTransportUsage, createWritableTransportEventStream, + encodeAssistantTextSignatureV1, failTransportStream, finalizeTransportStream, mergeTransportHeaders, @@ -116,7 +117,7 @@ function resolveAnthropicRequestModelId(model: AnthropicTransportModel): string } type TransportContentBlock = - | { type: "text"; text: string; index?: number } + | { type: "text"; text: string; index?: number; textSignature?: string } | { type: "thinking"; thinking: string; @@ -581,6 +582,25 @@ function mapStopReason(reason: string | undefined): string { } } +function tagPendingCommentaryText(content: TransportContentBlock[]): void { + let commentaryTextIndex = content.filter( + (block) => block.type === "text" && block.textSignature !== undefined, + ).length; + for (const block of content) { + if ( + block.type === "text" && + block.text.trim().length > 0 && + block.textSignature === undefined + ) { + block.textSignature = encodeAssistantTextSignatureV1( + `commentary-${commentaryTextIndex}`, + "commentary", + ); + commentaryTextIndex += 1; + } + } +} + const DEFAULT_ANTHROPIC_BASE_URL = "https://api.anthropic.com"; /** Resolve the effective Anthropic API base URL from model or environment. */ @@ -975,9 +995,13 @@ function buildAnthropicParams( if (fable5 || model.reasoning || supportsAdaptiveThinking(model)) { if (fable5 || options?.thinkingEnabled) { if (supportsAdaptiveThinking(model)) { - params.thinking = fable5 - ? { type: "adaptive", display: "summarized" } - : { type: "adaptive" }; + // Default display to "summarized" so Opus 4.7+/Fable 5 return a thinking + // summary like older Claude 4 models — mirrors the provider path + // (llm/providers/anthropic.ts). Without it the adaptive request omits the + // summary and only an encrypted signature comes back, so the 🧠 lane is + // blank (the live agent transport previously sent this for opus-4-8). + const display: AnthropicThinkingDisplay = options?.thinkingDisplay ?? "summarized"; + params.thinking = { type: "adaptive", display }; const effort = options?.effort ?? (fable5 ? "high" : undefined); if (effort) { params.output_config = { effort }; @@ -1122,6 +1146,14 @@ export function createAnthropicMessagesTransportStreamFn(): StreamFn { const reasoningContentThinkingBlocks = new Map(); const reasoningContentTextBlocks = new Map(); let sawMessageStop = false; + const pendingTextEnds: Array[0]> = []; + // Hold text_end until tool-boundary classification is known. + const flushPendingTextEnds = () => { + for (const event of pendingTextEnds) { + eventSink.push(event); + } + pendingTextEnds.length = 0; + }; const eventIndexKey = (eventIndex: unknown) => typeof eventIndex === "number" ? eventIndex : -1; const appendReasoningContentThinkingDelta = ( @@ -1339,6 +1371,8 @@ export function createAnthropicMessagesTransportStreamFn(): StreamFn { continue; } if (contentBlock?.type === "tool_use") { + tagPendingCommentaryText(output.content); + flushPendingTextEnds(); const block: TransportContentBlock = { type: "toolCall", id: typeof contentBlock.id === "string" ? contentBlock.id : "", @@ -1489,7 +1523,7 @@ export function createAnthropicMessagesTransportStreamFn(): StreamFn { blockIndexes.delete(eventIndex); delete block.index; if (block.type === "text") { - eventSink.push({ + pendingTextEnds.push({ type: "text_end", contentIndex: index, content: block.text, @@ -1553,6 +1587,16 @@ export function createAnthropicMessagesTransportStreamFn(): StreamFn { output.usage.cacheRead + output.usage.cacheWrite; calculateCost(model, output.usage); + // Gate on the turn CONTAINING a tool call, not the provider's stop_reason + // label: Bedrock/Vertex-proxied routes (e.g. pioneer) report "end_turn" on + // tool-using turns. No-op for direct Anthropic (already "toolUse" here). + if ( + output.stopReason === "toolUse" || + output.content.some((block) => block.type === "toolCall") + ) { + tagPendingCommentaryText(output.content); + } + flushPendingTextEnds(); } } if (refusalBuffer && !sawMessageStop) { @@ -1565,6 +1609,18 @@ export function createAnthropicMessagesTransportStreamFn(): StreamFn { throw new Error(output.errorMessage ?? "An unknown error occurred"); } refusalBuffer?.flush(); + // Backstop: streaming tags commentary at the tool-boundary above, but + // replay/non-streaming assembly may reach here with tool calls untagged. + // Idempotent, so it never double-tags the streaming path. Gate on the turn + // containing a tool call (not stop_reason) so proxied Bedrock/Vertex routes + // that mislabel tool turns as "end_turn" still tag their narration. + if ( + output.stopReason === "toolUse" || + output.content.some((block) => block.type === "toolCall") + ) { + tagPendingCommentaryText(output.content); + } + flushPendingTextEnds(); finalizeTransportStream({ stream, output }); } catch (error) { if (refusalBuffer) { diff --git a/src/agents/embedded-agent-payloads.ts b/src/agents/embedded-agent-payloads.ts index b471f9a398a8..df3daa9e3d13 100644 --- a/src/agents/embedded-agent-payloads.ts +++ b/src/agents/embedded-agent-payloads.ts @@ -9,6 +9,8 @@ export type BlockReplyPayload = { trustedLocalMedia?: boolean; sensitiveMedia?: boolean; isReasoning?: boolean; + /** Marks pre-tool commentary (💬) — a display lane, suppressed unless the channel opts in. */ + isCommentary?: boolean; replyToId?: string; replyToTag?: boolean; replyToCurrent?: boolean; diff --git a/src/agents/embedded-agent-runner/delivery-evidence.ts b/src/agents/embedded-agent-runner/delivery-evidence.ts index 3a5d82b664ee..eaa4df7ee72b 100644 --- a/src/agents/embedded-agent-runner/delivery-evidence.ts +++ b/src/agents/embedded-agent-runner/delivery-evidence.ts @@ -19,6 +19,8 @@ type AgentPayloadLike = { attachments?: unknown; isError?: unknown; isReasoning?: unknown; + /** Marks pre-tool commentary (💬) — a display lane, suppressed unless the channel opts in. */ + isCommentary?: unknown; }; type AgentDeliveryEvidence = { diff --git a/src/agents/embedded-agent-runner/run.ts b/src/agents/embedded-agent-runner/run.ts index 0932b716a19f..0db7c7de4a30 100644 --- a/src/agents/embedded-agent-runner/run.ts +++ b/src/agents/embedded-agent-runner/run.ts @@ -2225,6 +2225,7 @@ async function runEmbeddedAgentInternal( blockReplyBreak: params.blockReplyBreak, blockReplyChunking: params.blockReplyChunking, onReasoningStream: params.onReasoningStream, + streamReasoningInNonStreamModes: params.streamReasoningInNonStreamModes, onReasoningEnd: params.onReasoningEnd, onToolResult: notifyToolResult, onAgentToolResult: params.onAgentToolResult, diff --git a/src/agents/embedded-agent-runner/run/attempt.ts b/src/agents/embedded-agent-runner/run/attempt.ts index 51fd6c5176d6..47e307b8d4fd 100644 --- a/src/agents/embedded-agent-runner/run/attempt.ts +++ b/src/agents/embedded-agent-runner/run/attempt.ts @@ -3581,6 +3581,7 @@ export async function runEmbeddedAttempt( onAgentToolResult: params.onAgentToolResult, onToolResult: params.onToolResult, onReasoningStream: params.onReasoningStream, + streamReasoningInNonStreamModes: params.streamReasoningInNonStreamModes, onReasoningEnd: params.onReasoningEnd, onBlockReply, onBlockReplyFlush, diff --git a/src/agents/embedded-agent-runner/run/params.ts b/src/agents/embedded-agent-runner/run/params.ts index a4ed7e8e5751..84e76ba69a50 100644 --- a/src/agents/embedded-agent-runner/run/params.ts +++ b/src/agents/embedded-agent-runner/run/params.ts @@ -39,6 +39,13 @@ export type { ClientToolDefinition } from "../../command/shared-types.js"; export type EmbeddedRunTrigger = "cron" | "heartbeat" | "manual" | "memory" | "overflow" | "user"; +type ReasoningStreamPayload = Pick< + ReplyPayload, + "text" | "mediaUrls" | "isReasoning" | "isReasoningSnapshot" +> & { + requiresReasoningProgressOptIn?: boolean; +}; + export type CurrentInboundPromptContext = { text: string; resumableText?: string; @@ -227,11 +234,8 @@ export type RunEmbeddedAgentParams = { onBlockReplyFlush?: () => void | Promise; blockReplyBreak?: "text_end" | "message_end"; blockReplyChunking?: BlockReplyChunking; - onReasoningStream?: (payload: { - text?: string; - mediaUrls?: string[]; - isReasoningSnapshot?: boolean; - }) => void | Promise; + onReasoningStream?: (payload: ReasoningStreamPayload) => void | Promise; + streamReasoningInNonStreamModes?: boolean; onReasoningEnd?: () => void | Promise; onToolResult?: (payload: ReplyPayload) => void | Promise; /** Synchronous private observer for the sanitized per-tool result. */ diff --git a/src/agents/embedded-agent-runner/run/payloads.ts b/src/agents/embedded-agent-runner/run/payloads.ts index dc2eb08205ef..ffac196b426f 100644 --- a/src/agents/embedded-agent-runner/run/payloads.ts +++ b/src/agents/embedded-agent-runner/run/payloads.ts @@ -590,6 +590,8 @@ export function buildEmbeddedRunPayloads(params: { mediaUrl?: string; isError?: boolean; isReasoning?: boolean; + /** Marks pre-tool commentary (💬) — a display lane, suppressed unless the channel opts in. */ + isCommentary?: boolean; audioAsVoice?: boolean; replyToId?: string; replyToTag?: boolean; diff --git a/src/agents/embedded-agent-runner/stream-resolution.ts b/src/agents/embedded-agent-runner/stream-resolution.ts index d6a93e9b05e4..29d5b26900ff 100644 --- a/src/agents/embedded-agent-runner/stream-resolution.ts +++ b/src/agents/embedded-agent-runner/stream-resolution.ts @@ -174,7 +174,16 @@ export function resolveEmbeddedAgentStreamFn(params: { if ( isDefaultOpenClawStreamFnForModel(params.model, params.currentStreamFn) || - hasResolvedRuntimeApiKey(params.resolvedApiKey) + hasResolvedRuntimeApiKey(params.resolvedApiKey) || + // Proxied anthropic-messages providers (provider !== "anthropic", e.g. pioneer) + // must use the boundary-aware managed transport even without a resolved runtime + // key — it is the only place a tool-using turn's narration gets tagged + // phase:commentary; the base SDK stream never tags it, so proxied anthropic + // providers silently lost their narration lane. Scoped to non-"anthropic" + // providers so direct-anthropic edge cases (thinking-replay repair without a + // resolved key) are unchanged; the wrap below injects the resolved key + // (fallback options.apiKey), preserving x-api-key auth. + (params.model.api === "anthropic-messages" && params.model.provider !== "anthropic") ) { const boundaryAwareStreamFn = createBoundaryAwareStreamFnForModel(params.model); if (boundaryAwareStreamFn) { diff --git a/src/agents/embedded-agent-runner/types.ts b/src/agents/embedded-agent-runner/types.ts index 37639ed1b85c..8f54f41dbb4c 100644 --- a/src/agents/embedded-agent-runner/types.ts +++ b/src/agents/embedded-agent-runner/types.ts @@ -193,6 +193,8 @@ export type EmbeddedAgentRunResult = { replyToId?: string; isError?: boolean; isReasoning?: boolean; + /** Marks pre-tool commentary (💬) — a display lane, suppressed unless the channel opts in. */ + isCommentary?: boolean; audioAsVoice?: boolean; trustedLocalMedia?: boolean; channelData?: Record; diff --git a/src/agents/embedded-agent-subscribe.handlers.messages.test.ts b/src/agents/embedded-agent-subscribe.handlers.messages.test.ts index a267ed9523fa..da2706a7e874 100644 --- a/src/agents/embedded-agent-subscribe.handlers.messages.test.ts +++ b/src/agents/embedded-agent-subscribe.handlers.messages.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it, vi } from "vitest"; import { createInlineCodeState } from "../../packages/markdown-core/src/code-spans.js"; import { createStreamingDirectiveAccumulator } from "../auto-reply/reply/streaming-directives.js"; import { + buildAssistantStreamData, consumePendingAssistantReplyDirectivesIntoReply, consumePendingToolMediaIntoReply, consumePendingToolMediaReply, @@ -11,6 +12,8 @@ import { handleMessageUpdate, hasAssistantVisibleReply, readPendingToolMediaReply, + recordPendingAssistantReplyDirectives, + resolveSilentReplyFallbackText, } from "./embedded-agent-subscribe.handlers.messages.js"; import type { EmbeddedAgentSubscribeContext } from "./embedded-agent-subscribe.handlers.types.js"; import { @@ -199,6 +202,50 @@ function createMessageToolEnvelope(message: string, args: Record { + it("replaces NO_REPLY with latest messaging tool text when available", () => { + expect( + resolveSilentReplyFallbackText({ + text: "NO_REPLY", + messagingToolSentTexts: ["first", "final delivered text"], + }), + ).toBe("final delivered text"); + }); + + it("keeps original text when response is not NO_REPLY", () => { + expect( + resolveSilentReplyFallbackText({ + text: "normal assistant reply", + messagingToolSentTexts: ["final delivered text"], + }), + ).toBe("normal assistant reply"); + }); + + it("keeps NO_REPLY when there is no messaging tool text to mirror", () => { + expect( + resolveSilentReplyFallbackText({ + text: "NO_REPLY", + messagingToolSentTexts: [], + }), + ).toBe("NO_REPLY"); + }); + + it("tolerates malformed text payloads without throwing", () => { + expect( + resolveSilentReplyFallbackText({ + text: undefined, + messagingToolSentTexts: ["final delivered text"], + }), + ).toBe(""); + expect( + resolveSilentReplyFallbackText({ + text: "NO_REPLY", + messagingToolSentTexts: [42 as unknown as string], + }), + ).toBe("42"); + }); +}); + describe("hasAssistantVisibleReply", () => { it("treats audio-only payloads as visible", () => { expect(hasAssistantVisibleReply({ audioAsVoice: true })).toBe(true); @@ -211,16 +258,38 @@ describe("hasAssistantVisibleReply", () => { }); }); +describe("buildAssistantStreamData", () => { + it("normalizes media payloads for assistant stream events", () => { + expect( + buildAssistantStreamData({ + text: "hello", + delta: "he", + replace: true, + mediaUrl: "https://example.com/a.png", + phase: "final_answer", + }), + ).toEqual({ + text: "hello", + delta: "he", + replace: true, + mediaUrls: ["https://example.com/a.png"], + phase: "final_answer", + }); + }); +}); + describe("pending assistant reply directives", () => { it("merges directive metadata into the next non-reasoning block reply", () => { - const state = { - pendingAssistantReplyDirectives: { - mediaUrls: ["/tmp/reply.ogg"], - replyToCurrent: true, - replyToTag: true, - audioAsVoice: true, - }, - }; + const state = { pendingAssistantReplyDirectives: undefined }; + + recordPendingAssistantReplyDirectives(state, { + text: "", + mediaUrls: ["/tmp/reply.ogg"], + replyToCurrent: true, + replyToTag: true, + audioAsVoice: true, + isSilent: false, + }); expect( consumePendingAssistantReplyDirectivesIntoReply(state, { @@ -405,6 +474,12 @@ describe("handleMessageUpdate text signatures", () => { ); expect(onAgentEvent.mock.calls.map(([event]) => event)).toMatchObject([ + // Emit-always: the commentary delta reaches the bus tagged with its + // phase; reply lanes still exclude it (covered below). + { + stream: "assistant", + data: { delta: "Hello", phase: "commentary", itemId: "item-commentary" }, + }, { stream: "assistant", data: { text: "Hello world", delta: "Hello world", phase: "final_answer" }, @@ -412,6 +487,54 @@ describe("handleMessageUpdate text signatures", () => { ]); }); + it("emits a commentary snapshot when Anthropic text is classified after deltas", () => { + const onAgentEvent = vi.fn(); + const context = createMessageUpdateContext({ onAgentEvent }); + const narration = "I'll check the repo first."; + const commentaryPartial = { + role: "assistant", + api: "anthropic-messages", + content: [ + { + type: "text", + text: narration, + textSignature: JSON.stringify({ v: 1, id: "commentary-0", phase: "commentary" }), + }, + ], + }; + + handleMessageUpdate(context, { + type: "message_update", + message: { + role: "assistant", + api: "anthropic-messages", + content: [{ type: "text", text: narration }], + }, + assistantMessageEvent: { type: "text_delta", delta: narration }, + } as never); + handleMessageUpdate(context, { + type: "message_update", + message: { role: "assistant", api: "anthropic-messages", content: [] }, + assistantMessageEvent: { + type: "text_end", + content: narration, + partial: commentaryPartial, + }, + } as never); + + expect(onAgentEvent.mock.calls.map(([event]) => event)).toContainEqual( + expect.objectContaining({ + stream: "assistant", + data: expect.objectContaining({ + text: narration, + replace: true, + phase: "commentary", + itemId: "commentary-0", + }), + }), + ); + }); + it("uses incremental deltas for same-item phased streams", () => { const onAgentEvent = vi.fn(); const context = createMessageUpdateContext({ onAgentEvent }); @@ -821,14 +944,16 @@ describe("handleMessageUpdate commentary phase", () => { await Promise.resolve(); - expect(onAgentEvent).not.toHaveBeenCalled(); + // Archive-always: commentary (textSignature-only phase — the F3 shape) is + // emitted on the bus for archival + window, but kept out of the reply lanes. + expect(onAgentEvent).toHaveBeenCalled(); expect(onPartialReply).not.toHaveBeenCalled(); expect(flushBlockReplyBuffer).not.toHaveBeenCalled(); expect(ctx.state.deltaBuffer).toBe(""); expect(ctx.state.blockBuffer).toBe(""); }); - it("suppresses commentary partials even when they contain visible text", () => { + it("keeps commentary partials out of reply lanes while emitting them on the bus", () => { const onAgentEvent = vi.fn(); const ctx = createMessageUpdateContext({ onAgentEvent, @@ -849,7 +974,15 @@ describe("handleMessageUpdate commentary phase", () => { }), ); - expect(onAgentEvent).not.toHaveBeenCalled(); + // Emit-always: the bus sees the commentary delta with its phase tag, but + // reply-text buffers stay untouched. + expect(onAgentEvent).toHaveBeenCalledTimes(1); + const commentaryEvent = firstMockArg(onAgentEvent, "agent event") as + | { stream?: string; data?: { delta?: string; phase?: string } } + | undefined; + expect(commentaryEvent?.stream).toBe("assistant"); + expect(commentaryEvent?.data?.phase).toBe("commentary"); + expect(commentaryEvent?.data?.delta).toBe("Working..."); expect(ctx.state.deltaBuffer).toBe(""); expect(ctx.state.blockBuffer).toBe(""); @@ -867,8 +1000,8 @@ describe("handleMessageUpdate commentary phase", () => { }), ); - expect(onAgentEvent).toHaveBeenCalledTimes(1); - const event = firstMockArg(onAgentEvent, "agent event") as + expect(onAgentEvent).toHaveBeenCalledTimes(2); + const event = onAgentEvent.mock.calls[1]?.[0] as | { stream?: string; data?: { text?: string; delta?: string } } | undefined; expect(event?.stream).toBe("assistant"); @@ -1014,7 +1147,8 @@ describe("handleMessageEnd", () => { }, } as never); - expect(onAgentEvent).not.toHaveBeenCalled(); + // Archive-always: commentary reaches the bus/archive but not the visible reply. + expect(onAgentEvent).toHaveBeenCalled(); expect(emitBlockReply).not.toHaveBeenCalled(); expect(finalizeAssistantTexts).not.toHaveBeenCalled(); }); @@ -1044,7 +1178,9 @@ describe("handleMessageEnd", () => { }, } as never); - expect(onAgentEvent).not.toHaveBeenCalled(); + // Archive-always: commentary (textSignature-only phase) reaches the + // bus/archive but not the visible reply. + expect(onAgentEvent).toHaveBeenCalled(); expect(emitBlockReply).not.toHaveBeenCalled(); expect(finalizeAssistantTexts).not.toHaveBeenCalled(); }); diff --git a/src/agents/embedded-agent-subscribe.handlers.messages.ts b/src/agents/embedded-agent-subscribe.handlers.messages.ts index 184622291618..68ec3495dfe7 100644 --- a/src/agents/embedded-agent-subscribe.handlers.messages.ts +++ b/src/agents/embedded-agent-subscribe.handlers.messages.ts @@ -34,6 +34,7 @@ import { warnIfAssistantEmittedToolText } from "./embedded-agent-subscribe.tool- import { extractAssistantText, extractAssistantThinking, + extractAssistantCommentaryText, extractAssistantVisibleText, extractThinkingFromTaggedStream, extractThinkingFromTaggedText, @@ -63,6 +64,14 @@ function isOpenAiResponsesAssistantMessage(message: AgentMessage | undefined): b return api === "openai-responses" || api === "azure-openai-responses"; } +function isAnthropicAssistantMessage(message: AgentMessage | undefined): boolean { + if (!message || message.role !== "assistant") { + return false; + } + const api = normalizeOptionalString((message as { api?: unknown }).api) ?? ""; + return api === "anthropic-messages"; +} + function isOpenAiCompletionsAssistantMessage(message: AgentMessage | undefined): boolean { if (!message || message.role !== "assistant") { return false; @@ -271,7 +280,7 @@ function copyPartialBlockState( } /** Replaces a silent-reply token with the latest sent messaging-tool text when available. */ -function resolveSilentReplyFallbackText(params: { +export function resolveSilentReplyFallbackText(params: { text: unknown; messagingToolSentTexts: string[]; }): string { @@ -475,7 +484,7 @@ function resolveStreamingReplyText(params: { } /** Records parsed reply directives until a sendable reply payload is built. */ -function recordPendingAssistantReplyDirectives( +export function recordPendingAssistantReplyDirectives( state: Pick, parsed: ReplyDirectiveParseResult | null | undefined, ) { @@ -529,19 +538,21 @@ export function hasAssistantVisibleReply(params: { } /** Builds normalized stream payload data for assistant visible output. */ -function buildAssistantStreamData(params: { +export function buildAssistantStreamData(params: { text?: string; delta?: string; replace?: boolean; mediaUrls?: string[]; mediaUrl?: string; phase?: AssistantPhase; + itemId?: string; }): { text: string; delta: string; replace?: true; mediaUrls?: string[]; phase?: AssistantPhase; + itemId?: string; } { const mediaUrls = resolveSendableOutboundReplyParts(params).mediaUrls; return { @@ -550,6 +561,7 @@ function buildAssistantStreamData(params: { replace: params.replace ? true : undefined, mediaUrls: mediaUrls.length ? mediaUrls : undefined, phase: params.phase, + itemId: params.itemId, }; } @@ -586,6 +598,21 @@ export function handleMessageUpdate( ctx.noteLastAssistant(msg); const suppressVisibleAssistantOutput = shouldSuppressAssistantVisibleOutput(msg); if (suppressVisibleAssistantOutput) { + const commentaryText = coerceChatContentText(extractAssistantCommentaryText(msg)); + if (commentaryText) { + appendRawStream({ + ts: Date.now(), + event: "assistant_text_stream", + runId: ctx.params.runId, + sessionId: (ctx.params.session as { id?: string }).id, + evtType: "commentary_update", + delta: "", + content: commentaryText, + }); + ctx.emitAssistantStreamData( + buildAssistantStreamData({ text: commentaryText, replace: true, phase: "commentary" }), + ); + } return; } const suppressDeterministicApprovalOutput = shouldSuppressDeterministicApprovalOutput(ctx.state); @@ -625,12 +652,18 @@ export function handleMessageUpdate( delta: thinkingDelta, content: thinkingContent, }); - if (!suppressMessageToolOnlySourceReplyOutput && ctx.state.streamReasoning) { - // Prefer full partial-message thinking when available; fall back to event payloads. - const partialThinking = extractAssistantThinking(msg); - ctx.emitReasoningStream(partialThinking || thinkingContent || thinkingDelta); - } - if (!suppressMessageToolOnlySourceReplyOutput && evtType === "thinking_end") { + // Emit-always: emitReasoningStream always reaches the bus/archive; the + // streamReasoning rendering hook and message_tool_only source suppression + // are gated downstream (dispatch wrapProgressCallback, #92738), so emission + // here stays unconditional. + // Prefer full partial-message thinking when available; fall back to event payloads. + const partialThinking = extractAssistantThinking(msg); + ctx.emitReasoningStream(partialThinking || thinkingContent || thinkingDelta); + if (evtType === "thinking_end" && !suppressMessageToolOnlySourceReplyOutput) { + // Mirror the open gate above: when message-tool-only delivery has made the + // reasoning lane private, do not force-open it just to close it — that + // would fire the lane's end hook (onReasoningEnd) for a lane that never + // rendered, leaking the boundary signal. if (!ctx.state.reasoningStreamOpen) { openReasoningStream(ctx); } @@ -677,6 +710,10 @@ export function handleMessageUpdate( !deliveryPhase && Boolean(streamItemId) && isOpenAiResponsesAssistantMessage(partialAssistant); + // Anthropic commentary is known only at the tool boundary; keep early + // unphased deltas out of durable block replies until that phase is known. + const isPhasePendingAnthropicText = + evtType !== "text_end" && !deliveryPhase && isAnthropicAssistantMessage(partialAssistant); let streamItemChanged = false; if ((deliveryPhase || isPhasePendingOpenAiResponsesTextItem) && streamItemId) { const previousStreamItemId = ctx.state.lastAssistantStreamItemId; @@ -689,6 +726,22 @@ export function handleMessageUpdate( ctx.state.lastAssistantStreamItemId = streamItemId; } if (deliveryPhase === "commentary") { + const commentaryText = chunk + ? undefined + : coerceChatContentText(extractAssistantCommentaryText(partialAssistant)); + const commentaryData = chunk + ? buildAssistantStreamData({ delta: chunk, phase: "commentary", itemId: streamItemId }) + : commentaryText + ? buildAssistantStreamData({ + text: commentaryText, + replace: true, + phase: "commentary", + itemId: streamItemId, + }) + : undefined; + if (commentaryData) { + ctx.emitAssistantStreamData(commentaryData); + } return; } if (isPhasePendingOpenAiResponsesTextItem) { @@ -702,7 +755,7 @@ export function handleMessageUpdate( if (chunk) { ctx.state.deltaBuffer += chunk; - if (!skipLiveStream && !shouldUsePhaseAwareBlockReply) { + if (!skipLiveStream && !shouldUsePhaseAwareBlockReply && !isPhasePendingAnthropicText) { appendBlockReplyChunk(ctx, chunk); } } @@ -711,10 +764,10 @@ export function handleMessageUpdate( return; } - if (!suppressMessageToolOnlySourceReplyOutput && ctx.state.streamReasoning) { - // Handle partial tags: stream whatever reasoning is visible so far. - ctx.emitReasoningStream(extractThinkingFromTaggedStream(ctx.state.deltaBuffer)); - } + // Handle partial tags: stream whatever reasoning is visible so far. + // Emit-always: emitReasoningStream reaches the bus/archive; rendering + + // message_tool_only suppression are gated downstream (#92738). + ctx.emitReasoningStream(extractThinkingFromTaggedStream(ctx.state.deltaBuffer)); const wasThinking = ctx.state.partialBlockState.thinking; let visibleDelta = ""; const shouldReadPhaseAwarePartialText = @@ -920,6 +973,36 @@ export function handleMessageEnd( ctx.recordAssistantUsage((assistantMessage as { usage?: unknown }).usage); ctx.commitAssistantUsage(); if (suppressVisibleAssistantOutput) { + const commentaryText = coerceChatContentText(extractAssistantCommentaryText(assistantMessage)); + appendRawStream({ + ts: Date.now(), + event: "assistant_message_end", + runId: ctx.params.runId, + sessionId: (ctx.params.session as { id?: string }).id, + rawText: coerceChatContentText(extractAssistantText(assistantMessage)), + rawThinking: extractAssistantThinking(assistantMessage), + }); + if (commentaryText) { + ctx.emitAssistantStreamData( + buildAssistantStreamData({ text: commentaryText, replace: true, phase: "commentary" }), + ); + } + // Commentary-tagged tool turns can still carry durable reasoning under /reasoning on. + const suppressedTrimmedReasoning = ctx.state.includeReasoning + ? extractAssistantThinking(assistantMessage).trim() + : ""; + if ( + !ctx.params.silentExpected && + !suppressDeterministicApprovalOutput && + !suppressMessageToolOnlySourceReplyOutput && + ctx.state.includeReasoning && + suppressedTrimmedReasoning && + ctx.params.onBlockReply && + suppressedTrimmedReasoning !== ctx.state.lastReasoningSent + ) { + ctx.state.lastReasoningSent = suppressedTrimmedReasoning; + ctx.emitBlockReply({ text: suppressedTrimmedReasoning, isReasoning: true }); + } return; } promoteThinkingTagsToBlocks(assistantMessage); @@ -1051,6 +1134,8 @@ export function handleMessageEnd( return; } ctx.state.lastReasoningSent = trimmedReasoning; + // Lane purity: the payload carries raw thinking only. Tool persistence is + // the verbose lane's job; interleaving comes from arrival order. ctx.emitBlockReply({ text: trimmedReasoning, isReasoning: true }); }; @@ -1174,12 +1259,9 @@ export function handleMessageEnd( if (!shouldEmitReasoningBeforeAnswer) { maybeEmitReasoning(); } - if ( - !ctx.params.silentExpected && - !suppressMessageToolOnlySourceReplyOutput && - ctx.state.streamReasoning && - rawThinking - ) { + if (!ctx.params.silentExpected && rawThinking) { + // Emit-always: bus/archive get message-end thinking regardless of the + // streamReasoning rendering setting (gated inside emitReasoningStream). ctx.emitReasoningStream(rawThinking); } diff --git a/src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.subscribeembeddedagentsession.test.ts b/src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.subscribeembeddedagentsession.test.ts index dd77e3673582..e5745136eb55 100644 --- a/src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.subscribeembeddedagentsession.test.ts +++ b/src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.subscribeembeddedagentsession.test.ts @@ -885,6 +885,70 @@ describe("subscribeEmbeddedAgentSession", () => { expect(onReasoningEnd).toHaveBeenCalledTimes(1); }); + type ReasoningWindowGateCase = { + label: string; + reasoningMode: "off" | "stream"; + streamReasoningInNonStreamModes?: boolean; + expected: boolean; + }; + + it.each([ + { + label: "absent opt-in with off reasoning", + reasoningMode: "off", + expected: false, + }, + { + label: "false opt-in with off reasoning", + reasoningMode: "off", + streamReasoningInNonStreamModes: false, + expected: false, + }, + { + label: "false opt-in with stream reasoning", + reasoningMode: "stream", + streamReasoningInNonStreamModes: false, + expected: true, + }, + { + label: "true opt-in with off reasoning", + reasoningMode: "off", + streamReasoningInNonStreamModes: true, + expected: true, + }, + ])("gates reasoning-window streaming for $label", (params) => { + const onReasoningStream = vi.fn(); + const { emit } = createSubscribedHarness({ + runId: "run", + reasoningMode: params.reasoningMode, + ...(params.streamReasoningInNonStreamModes === undefined + ? {} + : { streamReasoningInNonStreamModes: params.streamReasoningInNonStreamModes }), + onReasoningStream, + }); + + emit({ + type: "message_update", + message: { + role: "assistant", + content: [{ type: "thinking", thinking: "Checking files" }], + }, + assistantMessageEvent: { + type: "thinking_delta", + delta: "Checking files", + }, + }); + + if (params.expected) { + expect(onReasoningStream).toHaveBeenCalledWith({ + text: "Checking files", + ...(params.reasoningMode === "stream" ? {} : { requiresReasoningProgressOptIn: true }), + }); + } else { + expect(onReasoningStream).not.toHaveBeenCalled(); + } + }); + it("extracts correct reasoning delta for incremental stream updates", () => { const emitAgentEventSpy = vi.spyOn(agentEvents, "emitAgentEvent").mockImplementation(() => {}); const { emit } = createSubscribedHarness({ diff --git a/src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.withholds-anthropic-pretool-narration.test.ts b/src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.withholds-anthropic-pretool-narration.test.ts new file mode 100644 index 000000000000..a97d9598c402 --- /dev/null +++ b/src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.withholds-anthropic-pretool-narration.test.ts @@ -0,0 +1,100 @@ +// Anthropic tags pre-tool narration commentary only at the tool_use boundary, so +// during the unphased deltas the subscriber must not durably commit the text on +// block-reply (text_end) channels — otherwise the mid-drain or the pre-tool +// flushBlockReplyBuffer posts the narration as an answer. A non-tool answer must +// still be delivered in full. +import type { AssistantMessage } from "openclaw/plugin-sdk/llm"; +import { describe, expect, it, vi } from "vitest"; +import { createStubSessionHarness } from "./embedded-agent-subscribe.e2e-harness.js"; +import { subscribeEmbeddedAgentSession } from "./embedded-agent-subscribe.js"; + +function anthropicAssistant(text: string, extra?: AssistantMessage["content"]): AssistantMessage { + return { + role: "assistant", + api: "anthropic-messages", + content: [{ type: "text", text }, ...(extra ?? [])], + } as unknown as AssistantMessage; +} + +function postedBlockReplyText(onBlockReply: ReturnType): string { + return onBlockReply.mock.calls.map((call) => call[0]?.text ?? "").join(" "); +} + +describe("subscribeEmbeddedAgentSession — Anthropic pre-tool narration", () => { + it("withholds pre-tool narration from durable block replies on a text_end channel", () => { + const { session, emit } = createStubSessionHarness(); + const onBlockReply = vi.fn(); + subscribeEmbeddedAgentSession({ + session: session as unknown as Parameters[0]["session"], + runId: "run-anthropic-withhold", + onBlockReply, + blockReplyBreak: "text_end", + // Tiny chunking would mid-drain the narration during deltas without the gate. + blockReplyChunking: { minChars: 4, maxChars: 200 }, + }); + + const narration = "Let me check the files before I answer. "; + emit({ type: "message_start", message: anthropicAssistant("") }); + emit({ + type: "message_update", + message: anthropicAssistant(narration), + assistantMessageEvent: { type: "text_delta", delta: narration }, + }); + // Tool execution flushes the block-reply buffer before running — the leak point. + emit({ + type: "tool_execution_start", + toolName: "bash", + toolCallId: "tool-1", + args: { command: "ls" }, + }); + // The turn resolves as a tool turn: the leading text is commentary. + emit({ + type: "message_end", + message: { + role: "assistant", + api: "anthropic-messages", + stopReason: "toolUse", + content: [ + { + type: "text", + text: narration, + textSignature: JSON.stringify({ v: 1, id: "commentary-0", phase: "commentary" }), + }, + { type: "toolCall", id: "tool-1", name: "bash", arguments: {} }, + ], + } as unknown as AssistantMessage, + }); + + expect(postedBlockReplyText(onBlockReply)).not.toContain("Let me check the files"); + }); + + it("still delivers a non-tool Anthropic answer in full on a text_end channel", async () => { + const { session, emit } = createStubSessionHarness(); + const onBlockReply = vi.fn(); + subscribeEmbeddedAgentSession({ + session: session as unknown as Parameters[0]["session"], + runId: "run-anthropic-answer", + onBlockReply, + blockReplyBreak: "text_end", + }); + + const answer = "Here is the full answer."; + emit({ type: "message_start", message: anthropicAssistant("") }); + emit({ + type: "message_update", + message: anthropicAssistant(answer), + assistantMessageEvent: { type: "text_delta", delta: answer }, + }); + emit({ + type: "message_update", + message: anthropicAssistant(answer), + assistantMessageEvent: { type: "text_end", contentIndex: 0 }, + }); + emit({ type: "message_end", message: anthropicAssistant(answer) }); + + await vi.waitFor(() => { + expect(onBlockReply).toHaveBeenCalled(); + }); + expect(postedBlockReplyText(onBlockReply)).toContain("Here is the full answer."); + }); +}); diff --git a/src/agents/embedded-agent-subscribe.ts b/src/agents/embedded-agent-subscribe.ts index 568f3c5eb530..7ba8d4e90ba9 100644 --- a/src/agents/embedded-agent-subscribe.ts +++ b/src/agents/embedded-agent-subscribe.ts @@ -181,7 +181,9 @@ export function subscribeEmbeddedAgentSession(params: SubscribeEmbeddedAgentSess includeReasoning: reasoningMode === "on" && canShowReasoning, shouldEmitPartialReplies: !(reasoningMode === "on" && !params.onBlockReply), streamReasoning: - reasoningMode === "stream" && + (params.streamReasoningInNonStreamModes === true + ? reasoningMode !== "on" + : reasoningMode === "stream") && canShowReasoning && typeof params.onReasoningStream === "function", deltaBuffer: "", @@ -1167,9 +1169,6 @@ export function subscribeEmbeddedAgentSession(params: SubscribeEmbeddedAgentSess if (params.silentExpected) { return; } - if (!state.streamReasoning || !params.onReasoningStream) { - return; - } const trimmed = text.trim(); if (!trimmed) { return; @@ -1183,7 +1182,10 @@ export function subscribeEmbeddedAgentSession(params: SubscribeEmbeddedAgentSess const delta = trimmed.startsWith(prior) ? trimmed.slice(prior.length) : trimmed; state.lastStreamedReasoning = trimmed; - // Broadcast thinking event to WebSocket clients in real-time + // Emit-always: the thinking stream always reaches the bus and session + // archive. /reasoning (streamReasoning) gates only the rendering hook + // below; display surfaces (TUI showThinking, webchat isReasoning drops) + // gate presentation on their side. emitAgentEvent({ runId: params.runId, stream: "thinking", @@ -1193,9 +1195,17 @@ export function subscribeEmbeddedAgentSession(params: SubscribeEmbeddedAgentSess }, }); - void params.onReasoningStream({ - text: trimmed, - }); + // Message-tool-only delivery makes later reasoning private: once the + // user-facing reply has gone out via the message tool, the channel shows + // only what was explicitly sent, so trailing reasoning must stay out of the + // render hook — uniformly, whether the thinking block rode in on a tool call + // or arrived on its own. It still reaches the bus/archive above. + if (state.streamReasoning && !hasMessageToolOnlySourceDelivery() && params.onReasoningStream) { + void params.onReasoningStream({ + text: trimmed, + ...(state.reasoningMode === "stream" ? {} : { requiresReasoningProgressOptIn: true }), + }); + } }; const resetForCompactionRetry = () => { diff --git a/src/agents/embedded-agent-subscribe.types.ts b/src/agents/embedded-agent-subscribe.types.ts index 457e2d23a503..02716170c2de 100644 --- a/src/agents/embedded-agent-subscribe.types.ts +++ b/src/agents/embedded-agent-subscribe.types.ts @@ -26,6 +26,13 @@ export type { ToolResultFormat, } from "./embedded-agent-subscribe.shared-types.js"; +type ReasoningStreamPayload = Pick< + ReplyPayload, + "text" | "mediaUrls" | "isReasoning" | "isReasoningSnapshot" +> & { + requiresReasoningProgressOptIn?: boolean; +}; + export type SubscribeEmbeddedAgentSessionParams = { session: AgentSession; runId: string; @@ -47,11 +54,9 @@ export type SubscribeEmbeddedAgentSessionParams = { hasDeliveredMessageToolOnlySourceReply?: () => boolean; onToolResult?: (payload: ReplyPayload) => void | Promise; onAgentToolResult?: (event: { toolName: string; result: unknown; isError: boolean }) => void; - onReasoningStream?: (payload: { - text?: string; - mediaUrls?: string[]; - isReasoningSnapshot?: boolean; - }) => void | Promise; + onReasoningStream?: (payload: ReasoningStreamPayload) => void | Promise; + /** Expands window reasoning beyond "stream" mode for callers with their own display gate. */ + streamReasoningInNonStreamModes?: boolean; /** Called when a thinking/reasoning block ends ( tag processed). */ onReasoningEnd?: () => void | Promise; onBlockReply?: (payload: BlockReplyPayload) => void | Promise; diff --git a/src/agents/embedded-agent-utils.test.ts b/src/agents/embedded-agent-utils.test.ts index 4e9395e0a9bf..6f64cebc7d80 100644 --- a/src/agents/embedded-agent-utils.test.ts +++ b/src/agents/embedded-agent-utils.test.ts @@ -673,7 +673,7 @@ describe("formatReasoningMessage", () => { }); describe("extractAssistantThinking", () => { - it("surfaces signed native reasoning even when the provider returns an empty summary", () => { + it("drops signature-only native reasoning blocks so no diagnostic bubble is surfaced", () => { const msg = makeAssistantMessage({ role: "assistant", content: [ @@ -687,9 +687,8 @@ describe("extractAssistantThinking", () => { timestamp: Date.now(), }); - expect(extractAssistantThinking(msg)).toBe( - "Native reasoning was produced; no summary text was returned.", - ); + // Signature-only block (no summary text) yields "" so downstream .filter(Boolean) drops it. + expect(extractAssistantThinking(msg)).toBe(""); }); }); diff --git a/src/agents/embedded-agent-utils.ts b/src/agents/embedded-agent-utils.ts index 109918fc9703..334a82b27f3e 100644 --- a/src/agents/embedded-agent-utils.ts +++ b/src/agents/embedded-agent-utils.ts @@ -143,6 +143,11 @@ export function extractAssistantVisibleText(msg: AssistantMessage): string { return extractAssistantTextForPhase(msg, undefined, { unphasedSignedFinalAnswer: true }).text; } +/** Extract the commentary/narration text of a commentary-phase assistant message. */ +export function extractAssistantCommentaryText(msg: AssistantMessage): string { + return extractAssistantTextForPhase(msg, "commentary").text; +} + /** Extract sanitized assistant text across all text content blocks. */ export function extractAssistantText(msg: AssistantMessage): string { const extracted = @@ -158,7 +163,7 @@ export function extractAssistantText(msg: AssistantMessage): string { return finalizeAssistantExtraction(msg, extracted); } -/** Extract native thinking block text or a placeholder when only signed reasoning exists. */ +/** Extract native thinking block text; signature-only blocks (no summary) surface nothing. */ export function extractAssistantThinking(msg: AssistantMessage): string { if (!Array.isArray(msg.content)) { return ""; @@ -174,8 +179,14 @@ export function extractAssistantThinking(msg: AssistantMessage): string { if (thinking) { return thinking; } + // Signature-only thinking blocks carry a valid signature but no summary text + // (e.g. Anthropic display:"omitted" on Opus 4.7+/Fable 5, or OpenAI/codex reasoning + // items with encrypted_content and an empty summary). Surface nothing so the + // .filter(Boolean) below drops the bubble — a diagnostic placeholder is not reasoning + // content and must not be shown on any channel. The signed block stays on the message + // for API replay; this only governs display. if (typeof record.thinkingSignature === "string" && record.thinkingSignature.trim()) { - return "Native reasoning was produced; no summary text was returned."; + return ""; } } return ""; diff --git a/src/agents/runtime-plan/types.ts b/src/agents/runtime-plan/types.ts index fb4178d40882..e5d3e536b66d 100644 --- a/src/agents/runtime-plan/types.ts +++ b/src/agents/runtime-plan/types.ts @@ -251,6 +251,8 @@ export type AgentRuntimeReplyPayload = { }; isError?: boolean; isReasoning?: boolean; + /** Marks pre-tool commentary (💬) — a display lane, suppressed unless the channel opts in. */ + isCommentary?: boolean; isReasoningSnapshot?: boolean; isCompactionNotice?: boolean; isFallbackNotice?: boolean; diff --git a/src/agents/transport-stream-shared.ts b/src/agents/transport-stream-shared.ts index 097fc5d0861a..dfcccf2632a2 100644 --- a/src/agents/transport-stream-shared.ts +++ b/src/agents/transport-stream-shared.ts @@ -30,6 +30,18 @@ type TransportOutputShape = { }; const EMPTY_TOOL_RESULT_TEXT = "(no output)"; +/** + * Encodes an assistant text-block phase signature (v1). Channels and the + * embedded handler read this to route commentary/narration out of the final + * reply. Shared so every provider transport tags phases identically. + */ +export function encodeAssistantTextSignatureV1( + id: string, + phase?: "commentary" | "final_answer", +): string { + return JSON.stringify({ v: 1, id, ...(phase ? { phase } : {}) }); +} + export function sanitizeTransportPayloadText(text: string): string { if (typeof text !== "string") { return ""; diff --git a/src/auto-reply/get-reply-options.types.ts b/src/auto-reply/get-reply-options.types.ts index 2420cb531465..1b99cec9b6ae 100644 --- a/src/auto-reply/get-reply-options.types.ts +++ b/src/auto-reply/get-reply-options.types.ts @@ -53,6 +53,13 @@ export type PartialReplyPayload = Pick & { replace?: true; }; +type ReasoningStreamPayload = Pick< + ReplyPayload, + "text" | "mediaUrls" | "isReasoning" | "isReasoningSnapshot" +> & { + requiresReasoningProgressOptIn?: boolean; +}; + /** Reply generation options shared by auto-reply, webchat, channels, and tests. */ export type GetReplyOptions = { /** Override run id for agent events (defaults to random UUID). */ @@ -118,7 +125,8 @@ export type GetReplyOptions = { */ onVerboseProgressVisibility?: (isActive: () => boolean) => void; onPartialReply?: (payload: PartialReplyPayload) => Promise | void; - onReasoningStream?: (payload: ReplyPayload) => Promise | void; + onReasoningStream?: (payload: ReasoningStreamPayload) => Promise | void; + streamReasoningInNonStreamModes?: boolean; /** Called when a thinking/reasoning block ends. */ onReasoningEnd?: () => Promise | void; /** Called when a new assistant message starts (e.g., after tool call or thinking block). */ @@ -157,6 +165,8 @@ export type GetReplyOptions = { commentaryProgressEnabled?: boolean; /** Deliver durable reasoning payloads to channels that own a separate reasoning lane. */ reasoningPayloadsEnabled?: boolean; + /** Deliver durable commentary (💬) payloads to channels that own a separate commentary lane. */ + commentaryPayloadsEnabled?: boolean; /** Called when the agent emits a structured plan update. */ onPlanUpdate?: (payload: { phase?: string; diff --git a/src/auto-reply/reply-payload.ts b/src/auto-reply/reply-payload.ts index 6d36db62c551..093490a4e357 100644 --- a/src/auto-reply/reply-payload.ts +++ b/src/auto-reply/reply-payload.ts @@ -48,6 +48,8 @@ export type ReplyPayload = { /** Marks this payload as a reasoning/thinking block. Channels that do not * have a dedicated reasoning lane (e.g. WhatsApp, web) should suppress it. */ isReasoning?: boolean; + /** Marks pre-tool commentary (💬) — a display lane, suppressed unless the channel opts in. */ + isCommentary?: boolean; /** Reasoning stream text is a complete replacement snapshot, not a delta. */ isReasoningSnapshot?: boolean; /** Marks this payload as a compaction status notice (start/end). diff --git a/src/auto-reply/reply/agent-runner-execution.test.ts b/src/auto-reply/reply/agent-runner-execution.test.ts index 09ccb93e3557..60b4e6b73d17 100644 --- a/src/auto-reply/reply/agent-runner-execution.test.ts +++ b/src/auto-reply/reply/agent-runner-execution.test.ts @@ -355,6 +355,12 @@ type EmbeddedAgentParams = { }) => void; onBlockReply?: (payload: { text?: string; mediaUrls?: string[] }) => Promise | void; onToolResult?: (payload: { text?: string; mediaUrls?: string[] }) => Promise | void; + onReasoningStream?: (payload: { + text?: string; + mediaUrls?: string[]; + isReasoningSnapshot?: boolean; + requiresReasoningProgressOptIn?: boolean; + }) => Promise | void; onItemEvent?: (payload: { itemId?: string; toolCallId?: string; @@ -3433,6 +3439,38 @@ describe("runAgentTurnWithFallback", () => { expect(onReasoningStream).not.toHaveBeenCalled(); }); + it("preserves embedded reasoning stream opt-in markers", async () => { + state.runEmbeddedAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => { + await params.onReasoningStream?.({ text: "stream thought" }); + await params.onReasoningStream?.({ + text: "ambient thought", + requiresReasoningProgressOptIn: true, + }); + return { payloads: [{ text: "final" }], meta: {} }; + }); + + const onReasoningStream = vi.fn>( + async (_payload) => undefined, + ); + const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + + await runAgentTurnWithFallback( + createMinimalRunAgentTurnParams({ + opts: { onReasoningStream }, + }), + ); + + expect( + onReasoningStream.mock.calls.map(([payload]) => ({ + text: payload.text, + requiresReasoningProgressOptIn: payload.requiresReasoningProgressOptIn, + })), + ).toEqual([ + { text: "stream thought", requiresReasoningProgressOptIn: undefined }, + { text: "ambient thought", requiresReasoningProgressOptIn: true }, + ]); + }); + it("resolves CLI messageProvider from the live session surface when no origin channel is set", async () => { state.isCliProviderMock.mockReturnValue(true); state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({ diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index 4605332b2ae7..1a1359bdbea7 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -2320,7 +2320,10 @@ export async function runAgentTurnWithFallback(params: { await params.opts.onPartialReply({ text: textForTyping }); }, onReasoningText: async (text) => { - await params.opts?.onReasoningStream?.({ text }); + await params.opts?.onReasoningStream?.({ + text, + requiresReasoningProgressOptIn: true, + }); }, onToolEvent: async (payload) => { await cliToolSummaryTracker.noteToolEvent(payload); @@ -2621,246 +2624,286 @@ export async function runAgentTurnWithFallback(params: { text: payload.text, mediaUrls: payload.mediaUrls, isReasoningSnapshot: payload.isReasoningSnapshot, + requiresReasoningProgressOptIn: + payload.requiresReasoningProgressOptIn, }); } : undefined, + streamReasoningInNonStreamModes: params.opts?.streamReasoningInNonStreamModes, onReasoningEnd: params.opts?.onReasoningEnd, - onAgentEvent: async (evt) => { - lifecycleBackstop.note(evt); - // Signal run start only after the embedded agent emits real activity. - const hasLifecyclePhase = - evt.stream === "lifecycle" && typeof evt.data.phase === "string"; - if (evt.stream !== "lifecycle" || hasLifecyclePhase) { - notifyAgentRunStart(); - } - // Trigger typing when tools start executing. - // Must await to ensure typing indicator starts before tool summaries are emitted. - if (evt.stream === "tool") { - const phase = readStringValue(evt.data.phase) ?? ""; - const name = readStringValue(evt.data.name); - const toolCallId = readStringValue(evt.data.toolCallId) ?? ""; - const args = - evt.data.args && typeof evt.data.args === "object" - ? (evt.data.args as Record) - : undefined; - if ( + onAgentEvent: (() => { + // Normalize commentary deltas/snapshots into full preamble text. + const commentaryTextByItem = new Map(); + const lastEmittedCommentaryByItem = new Map(); + return async (evt) => { + lifecycleBackstop.note(evt); + // Signal run start only after the embedded agent emits real activity. + const hasLifecyclePhase = + evt.stream === "lifecycle" && typeof evt.data.phase === "string"; + if (evt.stream !== "lifecycle" || hasLifecyclePhase) { + notifyAgentRunStart(); + } + // Trigger typing when tools start executing. + // Must await to ensure typing indicator starts before tool summaries are emitted. + if (evt.stream === "tool") { + const phase = readStringValue(evt.data.phase) ?? ""; + const name = readStringValue(evt.data.name); + const toolCallId = readStringValue(evt.data.toolCallId) ?? ""; + const args = + evt.data.args && typeof evt.data.args === "object" + ? (evt.data.args as Record) + : undefined; + if ( + sourceRepliesAreToolOnly && + toolCallId && + name && + (phase === "start" || phase === "update") && + args && + isMessagingToolSendAction(name, args) + ) { + messageToolOnlyDeliveryToolCallIds.add(toolCallId); + } + if (shouldSuppressProgressAfterMessageToolDelivery()) { + return; + } + if (phase === "start" || phase === "update") { + const toolStartProgressPromise = params.opts?.onToolStart?.({ + itemId: readStringValue(evt.data.itemId), + toolCallId: readStringValue(evt.data.toolCallId), + name, + phase, + args, + detailMode: params.toolProgressDetail, + }); + await Promise.all([ + params.typingSignals.signalToolStart(), + toolStartProgressPromise, + ]); + } + const commandOutput = buildCommandOutputFromToolResultEvent(evt); + if (commandOutput) { + await params.opts?.onCommandOutput?.(commandOutput); + } + } + const suppressItemChannelProgress = + evt.stream === "item" && + evt.data.suppressChannelProgress === true && + Boolean(params.opts?.onToolStart); + const itemPhase = + evt.stream === "item" ? readStringValue(evt.data.phase) : ""; + const itemName = + evt.stream === "item" ? readStringValue(evt.data.name) : ""; + const itemStatus = + evt.stream === "item" ? readStringValue(evt.data.status) : ""; + const itemToolCallId = + evt.stream === "item" ? (readStringValue(evt.data.toolCallId) ?? "") : ""; + const completedMessageToolDelivery = sourceRepliesAreToolOnly && - toolCallId && - name && - (phase === "start" || phase === "update") && - args && - isMessagingToolSendAction(name, args) + itemPhase === "end" && + itemStatus === "completed" && + itemToolCallId.length > 0 && + messageToolOnlyDeliveryToolCallIds.has(itemToolCallId); + const suppressProgressAfterMessageToolDelivery = + shouldSuppressProgressAfterMessageToolDelivery(); + if (completedMessageToolDelivery) { + messageToolOnlyDeliveryToolCallIds.delete(itemToolCallId); + messageToolOnlyDeliveryCompleted = true; + } + if ( + evt.stream === "assistant" && + readStringValue(evt.data.phase) === "commentary" && + !shouldSuppressProgressAfterMessageToolDelivery() ) { - messageToolOnlyDeliveryToolCallIds.add(toolCallId); + const commentaryItemId = readStringValue(evt.data.itemId) ?? ""; + const snapshotText = readStringValue(evt.data.text); + const deltaText = readStringValue(evt.data.delta); + const accumulated = + evt.data.replace === true && snapshotText + ? snapshotText + : deltaText + ? `${commentaryTextByItem.get(commentaryItemId) ?? ""}${deltaText}` + : (snapshotText ?? ""); + commentaryTextByItem.set(commentaryItemId, accumulated); + const commentaryText = accumulated.replace(/\s+/g, " ").trim(); + if ( + commentaryText && + lastEmittedCommentaryByItem.get(commentaryItemId) !== commentaryText + ) { + lastEmittedCommentaryByItem.set(commentaryItemId, commentaryText); + await params.opts?.onItemEvent?.({ + itemId: commentaryItemId || undefined, + kind: "preamble", + title: "Preamble", + phase: "update", + progressText: commentaryText, + }); + } } - if (shouldSuppressProgressAfterMessageToolDelivery()) { - return; - } - if (phase === "start" || phase === "update") { - const toolStartProgressPromise = params.opts?.onToolStart?.({ + if ( + evt.stream === "item" && + !suppressItemChannelProgress && + (!suppressProgressAfterMessageToolDelivery || + completedMessageToolDelivery) + ) { + await params.opts?.onItemEvent?.({ itemId: readStringValue(evt.data.itemId), toolCallId: readStringValue(evt.data.toolCallId), - name, - phase, - args, - detailMode: params.toolProgressDetail, + kind: readStringValue(evt.data.kind), + title: readStringValue(evt.data.title), + name: itemName, + phase: itemPhase, + status: itemStatus, + summary: readStringValue(evt.data.summary), + progressText: readStringValue(evt.data.progressText), + meta: readStringValue(evt.data.meta), + approvalId: readStringValue(evt.data.approvalId), + approvalSlug: readStringValue(evt.data.approvalSlug), }); - await Promise.all([ - params.typingSignals.signalToolStart(), - toolStartProgressPromise, - ]); } - const commandOutput = buildCommandOutputFromToolResultEvent(evt); - if (commandOutput) { - await params.opts?.onCommandOutput?.(commandOutput); - } - } - const suppressItemChannelProgress = - evt.stream === "item" && - evt.data.suppressChannelProgress === true && - Boolean(params.opts?.onToolStart); - const itemPhase = - evt.stream === "item" ? readStringValue(evt.data.phase) : ""; - const itemName = evt.stream === "item" ? readStringValue(evt.data.name) : ""; - const itemStatus = - evt.stream === "item" ? readStringValue(evt.data.status) : ""; - const itemToolCallId = - evt.stream === "item" ? (readStringValue(evt.data.toolCallId) ?? "") : ""; - const completedMessageToolDelivery = - sourceRepliesAreToolOnly && - itemPhase === "end" && - itemStatus === "completed" && - itemToolCallId.length > 0 && - messageToolOnlyDeliveryToolCallIds.has(itemToolCallId); - const suppressProgressAfterMessageToolDelivery = - shouldSuppressProgressAfterMessageToolDelivery(); - if (completedMessageToolDelivery) { - messageToolOnlyDeliveryToolCallIds.delete(itemToolCallId); - messageToolOnlyDeliveryCompleted = true; - } - if ( - evt.stream === "item" && - !suppressItemChannelProgress && - (!suppressProgressAfterMessageToolDelivery || completedMessageToolDelivery) - ) { - await params.opts?.onItemEvent?.({ - itemId: readStringValue(evt.data.itemId), - toolCallId: readStringValue(evt.data.toolCallId), - kind: readStringValue(evt.data.kind), - title: readStringValue(evt.data.title), - name: itemName, - phase: itemPhase, - status: itemStatus, - summary: readStringValue(evt.data.summary), - progressText: readStringValue(evt.data.progressText), - meta: readStringValue(evt.data.meta), - approvalId: readStringValue(evt.data.approvalId), - approvalSlug: readStringValue(evt.data.approvalSlug), - }); - } - if ( - evt.stream === "plan" && - !shouldSuppressProgressAfterMessageToolDelivery() - ) { - await params.opts?.onPlanUpdate?.({ - phase: readStringValue(evt.data.phase), - title: readStringValue(evt.data.title), - explanation: readStringValue(evt.data.explanation), - steps: Array.isArray(evt.data.steps) - ? evt.data.steps.filter( - (step): step is string => typeof step === "string", - ) - : undefined, - source: readStringValue(evt.data.source), - }); - } - if ( - evt.stream === "approval" && - !shouldSuppressProgressAfterMessageToolDelivery() - ) { - await params.opts?.onApprovalEvent?.({ - phase: readStringValue(evt.data.phase), - kind: readStringValue(evt.data.kind), - status: readStringValue(evt.data.status), - title: readStringValue(evt.data.title), - itemId: readStringValue(evt.data.itemId), - toolCallId: readStringValue(evt.data.toolCallId), - approvalId: readStringValue(evt.data.approvalId), - approvalSlug: readStringValue(evt.data.approvalSlug), - command: readStringValue(evt.data.command), - host: readStringValue(evt.data.host), - reason: readStringValue(evt.data.reason), - scope: readApprovalScopeValue(evt.data.scope), - message: readStringValue(evt.data.message), - }); - } - if ( - evt.stream === "command_output" && - !shouldSuppressProgressAfterMessageToolDelivery() - ) { - await params.opts?.onCommandOutput?.({ - itemId: readStringValue(evt.data.itemId), - phase: readStringValue(evt.data.phase), - title: readStringValue(evt.data.title), - toolCallId: readStringValue(evt.data.toolCallId), - name: readStringValue(evt.data.name), - output: readStringValue(evt.data.output), - status: readStringValue(evt.data.status), - exitCode: - typeof evt.data.exitCode === "number" || evt.data.exitCode === null - ? evt.data.exitCode + if ( + evt.stream === "plan" && + !shouldSuppressProgressAfterMessageToolDelivery() + ) { + await params.opts?.onPlanUpdate?.({ + phase: readStringValue(evt.data.phase), + title: readStringValue(evt.data.title), + explanation: readStringValue(evt.data.explanation), + steps: Array.isArray(evt.data.steps) + ? evt.data.steps.filter( + (step): step is string => typeof step === "string", + ) : undefined, - durationMs: - typeof evt.data.durationMs === "number" - ? evt.data.durationMs + source: readStringValue(evt.data.source), + }); + } + if ( + evt.stream === "approval" && + !shouldSuppressProgressAfterMessageToolDelivery() + ) { + await params.opts?.onApprovalEvent?.({ + phase: readStringValue(evt.data.phase), + kind: readStringValue(evt.data.kind), + status: readStringValue(evt.data.status), + title: readStringValue(evt.data.title), + itemId: readStringValue(evt.data.itemId), + toolCallId: readStringValue(evt.data.toolCallId), + approvalId: readStringValue(evt.data.approvalId), + approvalSlug: readStringValue(evt.data.approvalSlug), + command: readStringValue(evt.data.command), + host: readStringValue(evt.data.host), + reason: readStringValue(evt.data.reason), + scope: readApprovalScopeValue(evt.data.scope), + message: readStringValue(evt.data.message), + }); + } + if ( + evt.stream === "command_output" && + !shouldSuppressProgressAfterMessageToolDelivery() + ) { + await params.opts?.onCommandOutput?.({ + itemId: readStringValue(evt.data.itemId), + phase: readStringValue(evt.data.phase), + title: readStringValue(evt.data.title), + toolCallId: readStringValue(evt.data.toolCallId), + name: readStringValue(evt.data.name), + output: readStringValue(evt.data.output), + status: readStringValue(evt.data.status), + exitCode: + typeof evt.data.exitCode === "number" || evt.data.exitCode === null + ? evt.data.exitCode + : undefined, + durationMs: + typeof evt.data.durationMs === "number" + ? evt.data.durationMs + : undefined, + cwd: readStringValue(evt.data.cwd), + }); + } + if ( + evt.stream === "patch" && + !shouldSuppressProgressAfterMessageToolDelivery() + ) { + await params.opts?.onPatchSummary?.({ + itemId: readStringValue(evt.data.itemId), + phase: readStringValue(evt.data.phase), + title: readStringValue(evt.data.title), + toolCallId: readStringValue(evt.data.toolCallId), + name: readStringValue(evt.data.name), + added: Array.isArray(evt.data.added) + ? evt.data.added.filter( + (entry): entry is string => typeof entry === "string", + ) : undefined, - cwd: readStringValue(evt.data.cwd), - }); - } - if ( - evt.stream === "patch" && - !shouldSuppressProgressAfterMessageToolDelivery() - ) { - await params.opts?.onPatchSummary?.({ - itemId: readStringValue(evt.data.itemId), - phase: readStringValue(evt.data.phase), - title: readStringValue(evt.data.title), - toolCallId: readStringValue(evt.data.toolCallId), - name: readStringValue(evt.data.name), - added: Array.isArray(evt.data.added) - ? evt.data.added.filter( - (entry): entry is string => typeof entry === "string", - ) - : undefined, - modified: Array.isArray(evt.data.modified) - ? evt.data.modified.filter( - (entry): entry is string => typeof entry === "string", - ) - : undefined, - deleted: Array.isArray(evt.data.deleted) - ? evt.data.deleted.filter( - (entry): entry is string => typeof entry === "string", - ) - : undefined, - summary: readStringValue(evt.data.summary), - }); - } - if (evt.stream === "compaction") { - const phase = readStringValue(evt.data.phase) ?? ""; - const backend = readStringValue(evt.data.backend); - const hookMessages = readCompactionHookMessages(evt.data.messages); - const sendCompactionUserNotices = async ( - noticePhase: "start" | "end" | "incomplete", - ) => { - if (hookMessages.length > 0) { - await sendCompactionHookMessages(hookMessages); - } - if (notifyUserAboutCompaction) { - await sendCompactionNotice(noticePhase); - } - }; - if (phase === "start") { - if (params.opts?.onCompactionStart) { - await params.opts.onCompactionStart(); - } - await sendCompactionUserNotices("start"); + modified: Array.isArray(evt.data.modified) + ? evt.data.modified.filter( + (entry): entry is string => typeof entry === "string", + ) + : undefined, + deleted: Array.isArray(evt.data.deleted) + ? evt.data.deleted.filter( + (entry): entry is string => typeof entry === "string", + ) + : undefined, + summary: readStringValue(evt.data.summary), + }); } - if (phase === "end") { - const completed = evt.data?.completed === true; - if (completed) { - attemptCompactionCount += 1; - if (backend === CODEX_APP_SERVER_COMPACTION_BACKEND) { - const modelRef = formatCompactionModelRef(provider, model); - const consoleMessage = - `codex app-server auto-compaction succeeded for ${modelRef}; ` + - "refreshed session context"; - agentCompactionLog.info( - "codex app-server auto-compaction succeeded", - { - event: "codex_app_server_compaction_succeeded", - backend, - provider, - model, - sessionKey: params.sessionKey, - sessionId: effectiveRun.sessionId, - threadId: readStringValue(evt.data.threadId), - turnId: readStringValue(evt.data.turnId), - itemId: readStringValue(evt.data.itemId), - compactionCount: attemptCompactionCount, - consoleMessage, - }, - ); + if (evt.stream === "compaction") { + const phase = readStringValue(evt.data.phase) ?? ""; + const backend = readStringValue(evt.data.backend); + const hookMessages = readCompactionHookMessages(evt.data.messages); + const sendCompactionUserNotices = async ( + noticePhase: "start" | "end" | "incomplete", + ) => { + if (hookMessages.length > 0) { + await sendCompactionHookMessages(hookMessages); } - if (params.opts?.onCompactionEnd) { - await params.opts.onCompactionEnd(); + if (notifyUserAboutCompaction) { + await sendCompactionNotice(noticePhase); + } + }; + if (phase === "start") { + if (params.opts?.onCompactionStart) { + await params.opts.onCompactionStart(); + } + await sendCompactionUserNotices("start"); + } + if (phase === "end") { + const completed = evt.data?.completed === true; + if (completed) { + attemptCompactionCount += 1; + if (backend === CODEX_APP_SERVER_COMPACTION_BACKEND) { + const modelRef = formatCompactionModelRef(provider, model); + const consoleMessage = + `codex app-server auto-compaction succeeded for ${modelRef}; ` + + "refreshed session context"; + agentCompactionLog.info( + "codex app-server auto-compaction succeeded", + { + event: "codex_app_server_compaction_succeeded", + backend, + provider, + model, + sessionKey: params.sessionKey, + sessionId: effectiveRun.sessionId, + threadId: readStringValue(evt.data.threadId), + turnId: readStringValue(evt.data.turnId), + itemId: readStringValue(evt.data.itemId), + compactionCount: attemptCompactionCount, + consoleMessage, + }, + ); + } + if (params.opts?.onCompactionEnd) { + await params.opts.onCompactionEnd(); + } + await sendCompactionUserNotices("end"); + } else { + await sendCompactionUserNotices("incomplete"); } - await sendCompactionUserNotices("end"); - } else { - await sendCompactionUserNotices("incomplete"); } } - } - }, + }; + })(), // Always pass onBlockReply so flushBlockReplyBuffer works before tool execution, // even when regular block streaming is disabled. The handler sends directly // via opts.onBlockReply when the pipeline isn't available. diff --git a/src/auto-reply/reply/block-reply-coalescer.ts b/src/auto-reply/reply/block-reply-coalescer.ts index d8d07bf57140..e66166c2cfda 100644 --- a/src/auto-reply/reply/block-reply-coalescer.ts +++ b/src/auto-reply/reply/block-reply-coalescer.ts @@ -29,6 +29,7 @@ export function createBlockReplyCoalescer(params: { let bufferReplyToId: ReplyPayload["replyToId"]; let bufferAudioAsVoice: ReplyPayload["audioAsVoice"]; let bufferIsReasoning: ReplyPayload["isReasoning"]; + let bufferIsCommentary: ReplyPayload["isCommentary"]; let bufferIsCompactionNotice: ReplyPayload["isCompactionNotice"]; let bufferIsFallbackNotice: ReplyPayload["isFallbackNotice"]; let bufferIsStatusNotice: ReplyPayload["isStatusNotice"]; @@ -48,6 +49,7 @@ export function createBlockReplyCoalescer(params: { bufferReplyToId = undefined; bufferAudioAsVoice = undefined; bufferIsReasoning = undefined; + bufferIsCommentary = undefined; bufferIsCompactionNotice = undefined; bufferIsFallbackNotice = undefined; bufferIsStatusNotice = undefined; @@ -58,6 +60,7 @@ export function createBlockReplyCoalescer(params: { bufferReplyToId = payload.replyToId; bufferAudioAsVoice = payload.audioAsVoice; bufferIsReasoning = payload.isReasoning; + bufferIsCommentary = payload.isCommentary; bufferIsCompactionNotice = payload.isCompactionNotice; bufferIsFallbackNotice = payload.isFallbackNotice; bufferIsStatusNotice = payload.isStatusNotice; @@ -92,6 +95,7 @@ export function createBlockReplyCoalescer(params: { replyToId: bufferReplyToId, audioAsVoice: bufferAudioAsVoice, isReasoning: bufferIsReasoning, + isCommentary: bufferIsCommentary, isCompactionNotice: bufferIsCompactionNotice, isFallbackNotice: bufferIsFallbackNotice, isStatusNotice: bufferIsStatusNotice, @@ -107,8 +111,10 @@ export function createBlockReplyCoalescer(params: { !bufferAudioAsVoice && !payload.audioAsVoice && !payload.isReasoning && + !payload.isCommentary && !isReplyPayloadStatusNotice(payload) && !bufferIsReasoning && + !bufferIsCommentary && !isReplyPayloadStatusNotice({ isCompactionNotice: bufferIsCompactionNotice, isFallbackNotice: bufferIsFallbackNotice, @@ -173,6 +179,7 @@ export function createBlockReplyCoalescer(params: { const visibilityConflict = bufferText && (bufferIsReasoning !== payload.isReasoning || + bufferIsCommentary !== payload.isCommentary || bufferIsCompactionNotice !== payload.isCompactionNotice || bufferIsFallbackNotice !== payload.isFallbackNotice || isReplyPayloadStatusNotice({ diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts index bdf04f50d761..79e2a6f9b765 100644 --- a/src/auto-reply/reply/dispatch-from-config.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.test.ts @@ -7967,6 +7967,46 @@ describe("dispatchReplyFromConfig", () => { ]); }); + it("suppresses isCommentary payloads from final replies by default", async () => { + setNoAbort(); + const dispatcher = createDispatcher(); + const ctx = buildTestCtx({ Provider: "whatsapp" }); + const replyResolver = async () => + [ + { text: "commentary...", isCommentary: true }, + { text: "The answer is 42" }, + ] satisfies ReplyPayload[]; + await dispatchReplyFromConfig({ ctx, cfg: emptyConfig, dispatcher, replyResolver }); + const finalCalls = (dispatcher.sendFinalReply as ReturnType).mock.calls; + expect(finalCalls).toHaveLength(1); + expect((finalCalls[0]?.[0] as ReplyPayload | undefined)?.text).toBe("The answer is 42"); + }); + + it("delivers isCommentary final replies when the channel opts in", async () => { + setNoAbort(); + const dispatcher = createDispatcher(); + const ctx = buildTestCtx({ Provider: "discord", Surface: "discord" }); + const replyResolver = async () => + [ + { text: "commentary...", isCommentary: true }, + { text: "The answer is 42" }, + ] satisfies ReplyPayload[]; + + await dispatchReplyFromConfig({ + ctx, + cfg: emptyConfig, + dispatcher, + replyOptions: { commentaryPayloadsEnabled: true }, + replyResolver, + }); + + const finalCalls = (dispatcher.sendFinalReply as ReturnType).mock.calls; + expect(finalCalls.map((call) => (call[0] as ReplyPayload).text)).toEqual([ + "commentary...", + "The answer is 42", + ]); + }); + it("does not synthesize opted-in final reasoning payloads into TTS media", async () => { setNoAbort(); ttsMocks.state.synthesizeFinalAudio = true; @@ -7989,6 +8029,28 @@ describe("dispatchReplyFromConfig", () => { expect(dispatcher.sendFinalReply).toHaveBeenCalledWith(reasoningPayload); }); + it("does not synthesize opted-in final commentary payloads into TTS media", async () => { + setNoAbort(); + ttsMocks.state.synthesizeFinalAudio = true; + const dispatcher = createDispatcher(); + const ctx = buildTestCtx({ Provider: "discord", Surface: "discord" }); + const commentaryPayload = { + text: "commentary...", + isCommentary: true, + } satisfies ReplyPayload; + + await dispatchReplyFromConfig({ + ctx, + cfg: emptyConfig, + dispatcher, + replyOptions: { commentaryPayloadsEnabled: true }, + replyResolver: async () => commentaryPayload, + }); + + expect(ttsMocks.maybeApplyTtsToPayload).not.toHaveBeenCalled(); + expect(dispatcher.sendFinalReply).toHaveBeenCalledWith(commentaryPayload); + }); + it("suppresses isReasoning payloads from block replies (generic dispatch path)", async () => { setNoAbort(); const dispatcher = createDispatcher(); @@ -8054,6 +8116,69 @@ describe("dispatchReplyFromConfig", () => { expect(blockTtsCalls.map((call) => call.payload?.text)).toEqual(["The answer is 42"]); }); + it("suppresses isCommentary payloads from block replies by default", async () => { + setNoAbort(); + const dispatcher = createDispatcher(); + const ctx = buildTestCtx({ Provider: "whatsapp" }); + const blockReplySentTexts: string[] = []; + const replyResolver = async ( + _ctx: MsgContext, + opts?: GetReplyOptions, + ): Promise => { + await opts?.onBlockReply?.({ text: "commentary...", isCommentary: true }); + await opts?.onBlockReply?.({ text: "The answer is 42" }); + return { text: "The answer is 42" }; + }; + (dispatcher.sendBlockReply as ReturnType).mockImplementation( + (payload: ReplyPayload) => { + if (payload.text) { + blockReplySentTexts.push(payload.text); + } + return true; + }, + ); + await dispatchReplyFromConfig({ ctx, cfg: emptyConfig, dispatcher, replyResolver }); + expect(blockReplySentTexts).not.toContain("commentary..."); + expect(blockReplySentTexts).toContain("The answer is 42"); + }); + + it("delivers opted-in block commentary payloads without applying TTS", async () => { + setNoAbort(); + const dispatcher = createDispatcher(); + const ctx = buildTestCtx({ Provider: "discord", Surface: "discord" }); + const blockReplySentTexts: string[] = []; + const replyResolver = async ( + _ctx: MsgContext, + opts?: GetReplyOptions, + ): Promise => { + await opts?.onBlockReply?.({ text: "commentary...", isCommentary: true }); + await opts?.onBlockReply?.({ text: "The answer is 42" }); + return undefined; + }; + (dispatcher.sendBlockReply as ReturnType).mockImplementation( + (payload: ReplyPayload) => { + if (payload.text) { + blockReplySentTexts.push(payload.text); + } + return true; + }, + ); + + await dispatchReplyFromConfig({ + ctx, + cfg: emptyConfig, + dispatcher, + replyOptions: { commentaryPayloadsEnabled: true }, + replyResolver, + }); + + expect(blockReplySentTexts).toEqual(["commentary...", "The answer is 42"]); + const blockTtsCalls = ttsMocks.maybeApplyTtsToPayload.mock.calls + .map(([call]) => call as { kind?: unknown; payload?: ReplyPayload }) + .filter((call) => call.kind === "block"); + expect(blockTtsCalls.map((call) => call.payload?.text)).toEqual(["The answer is 42"]); + }); + it("strips split TTS directives from streamed block text before delivery", async () => { setNoAbort(); ttsMocks.state.synthesizeFinalAudio = true; diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index 7c04f517264f..413ff6c1a02d 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -2052,6 +2052,7 @@ export async function dispatchReplyFromConfig( suppressHookReplyLifecycle, } = sourceReplyPolicy; const reasoningPayloadsEnabled = params.replyOptions?.reasoningPayloadsEnabled === true; + const commentaryPayloadsEnabled = params.replyOptions?.commentaryPayloadsEnabled === true; const attachSourceReplyDeliveryMode = ( result: DispatchFromConfigResult, ): DispatchFromConfigResult => @@ -2495,7 +2496,7 @@ export async function dispatchReplyFromConfig( finalReplyDeliveryStarted = true; } const ttsPayload = - payload.isReasoning === true + payload.isReasoning === true || payload.isCommentary === true ? payload : await maybeApplyTtsToReplyPayload({ payload, @@ -3073,6 +3074,8 @@ export async function dispatchReplyFromConfig( suppressTyping: typing.suppressTyping, onPartialReply: wrapProgressCallback(params.replyOptions?.onPartialReply), onReasoningStream: wrapProgressCallback(params.replyOptions?.onReasoningStream), + streamReasoningInNonStreamModes: + params.replyOptions?.streamReasoningInNonStreamModes, onReasoningEnd: wrapProgressCallback(params.replyOptions?.onReasoningEnd), onAssistantMessageStart: wrapProgressCallback( params.replyOptions?.onAssistantMessageStart, @@ -3095,6 +3098,7 @@ export async function dispatchReplyFromConfig( canForwardSuppressedSourceItemEvents || params.replyOptions?.commentaryProgressEnabled, reasoningPayloadsEnabled, + commentaryPayloadsEnabled, onCommandOutput: wrapProgressCallback(params.replyOptions?.onCommandOutput, { forwardWhenSourceDeliverySuppressed: true, requiresToolSummaryVisibility: true, @@ -3314,6 +3318,7 @@ export async function dispatchReplyFromConfig( } if ( payload.isReasoning !== true && + payload.isCommentary !== true && hasOutboundReplyContent(payload, { trimText: true }) ) { markInboundDedupeReplayUnsafe(); @@ -3328,11 +3333,22 @@ export async function dispatchReplyFromConfig( if (payload.isReasoning === true && !reasoningPayloadsEnabled) { return; } + // Durable commentary is a channel-owned lane; generic channels keep the + // historical suppression unless they explicitly opt in. + if (payload.isCommentary === true && !commentaryPayloadsEnabled) { + return; + } // Accumulate block text for TTS generation after streaming. // Exclude status notices — they are informational UI signals - // and must not be synthesised into the spoken reply. + // and must not be synthesised into the spoken reply. Display + // lanes stay out too: they are presentation, never final text. const isStatusNotice = isReplyPayloadStatusNotice(payload); - if (payload.text && !isStatusNotice && payload.isReasoning !== true) { + if ( + payload.text && + !isStatusNotice && + payload.isReasoning !== true && + payload.isCommentary !== true + ) { const joinsBufferedTtsDirective = cleanBlockTtsDirectiveText?.hasBufferedDirectiveText() === true; if (accumulatedBlockText.length > 0) { @@ -3349,7 +3365,8 @@ export async function dispatchReplyFromConfig( payload.text && cleanBlockTtsDirectiveText && !isStatusNotice && - payload.isReasoning !== true + payload.isReasoning !== true && + payload.isCommentary !== true ? (() => { const text = cleanBlockTtsDirectiveText.push(payload.text); return copyReplyPayloadMetadata(payload, { @@ -3379,7 +3396,7 @@ export async function dispatchReplyFromConfig( return; } const ttsPayload = - payload.isReasoning === true + payload.isReasoning === true || payload.isCommentary === true ? visiblePayload : await maybeApplyTtsToReplyPayload({ payload: visiblePayload, @@ -3506,6 +3523,9 @@ export async function dispatchReplyFromConfig( if (reply.isReasoning === true && !reasoningPayloadsEnabled) { continue; } + if (reply.isCommentary === true && !commentaryPayloadsEnabled) { + continue; + } if (suppressDelivery && !shouldDeliverDespiteSourceReplySuppression(reply)) { if (hasOutboundReplyContent(reply, { trimText: true })) { logVerbose( diff --git a/src/auto-reply/reply/reply-delivery.ts b/src/auto-reply/reply/reply-delivery.ts index d66d940a5fab..5e46f11116ea 100644 --- a/src/auto-reply/reply/reply-delivery.ts +++ b/src/auto-reply/reply/reply-delivery.ts @@ -180,7 +180,14 @@ export function createBlockReplyDeliveryHandler(params: { trackingPayload: blockPayload, payload: blockPayload, }); - } else if (blockHasNonTextContent) { + } else if ( + blockHasNonTextContent || + blockPayload.isReasoning === true || + blockPayload.isCommentary === true + ) { + // Reasoning and commentary are separate display lanes that never merge into + // the assistant's final text, so they must be delivered directly even when + // block streaming is off; presentation gating stays downstream (dispatch gate). await sendDirectBlockReply({ onBlockReply: params.onBlockReply, directlySentBlockKeys: params.directlySentBlockKeys, diff --git a/src/channels/progress-draft-compositor.test.ts b/src/channels/progress-draft-compositor.test.ts index cbe0e51c5708..8de64aef16a2 100644 --- a/src/channels/progress-draft-compositor.test.ts +++ b/src/channels/progress-draft-compositor.test.ts @@ -1,10 +1,7 @@ // Progress draft compositor tests cover streamed draft composition for channel progress updates. import { describe, expect, it, vi } from "vitest"; import { createChannelProgressDraftCompositor } from "./progress-draft-compositor.js"; -import { - DEFAULT_PROGRESS_DRAFT_INITIAL_DELAY_MS, - mergeChannelProgressDraftLine, -} from "./streaming.js"; +import { DEFAULT_PROGRESS_DRAFT_INITIAL_DELAY_MS } from "./streaming.js"; describe("createChannelProgressDraftCompositor", () => { it("keeps the progress label visible when tool lines are hidden", async () => { @@ -24,112 +21,84 @@ describe("createChannelProgressDraftCompositor", () => { expect(update).toHaveBeenCalledWith("Shelling", { flush: true, lines: [] }); }); - it("materializes a label-only progress draft after upstream answer activity", async () => { - vi.useFakeTimers(); - try { - const update = vi.fn(); - const progress = createChannelProgressDraftCompositor({ - entry: { streaming: { mode: "progress", progress: { label: "Shelling" } } }, - mode: "progress", - active: true, - seed: "test", - update, - }); - - await progress.noteActivity(); - expect(update).not.toHaveBeenCalled(); - - await vi.advanceTimersByTimeAsync(DEFAULT_PROGRESS_DRAFT_INITIAL_DELAY_MS); - - expect(update).toHaveBeenCalledWith("Shelling", { flush: true, lines: [] }); - } finally { - vi.useRealTimers(); - } - }); - - it("does not materialize delayed answer activity after final delivery starts or lands", async () => { - vi.useFakeTimers(); - try { - for (const markFinal of ["markFinalReplyStarted", "markFinalReplyDelivered"] as const) { - const update = vi.fn(); - const progress = createChannelProgressDraftCompositor({ - entry: { streaming: { mode: "progress", progress: { label: "Shelling" } } }, - mode: "progress", - active: true, - seed: "test", - update, - }); - - await progress.noteActivity(); - progress[markFinal](); - await vi.advanceTimersByTimeAsync(DEFAULT_PROGRESS_DRAFT_INITIAL_DELAY_MS); - - expect(update).not.toHaveBeenCalled(); - expect(progress.hasStarted).toBe(false); - } - } finally { - vi.useRealTimers(); - } - }); - - it("starts a label-only progress draft on repeated upstream answer activity", async () => { - const update = vi.fn(); - const progress = createChannelProgressDraftCompositor({ - entry: { streaming: { mode: "progress", progress: { label: "Shelling" } } }, - mode: "progress", - active: true, - seed: "test", - update, - }); - - await progress.noteActivity(); - await progress.noteActivity(); - - expect(update).toHaveBeenCalledWith("Shelling", { flush: true, lines: [] }); - }); - - it("passes structured progress lines to draft updates", async () => { - const update = vi.fn(); - const progress = createChannelProgressDraftCompositor({ - entry: { streaming: { mode: "progress", progress: { label: "Shelling" } } }, - mode: "progress", - active: true, - seed: "test", - update, - }); - const line = { - kind: "tool" as const, - text: "🛠️ Exec: git status", - label: "Exec", - icon: "🛠️", - detail: "git status", - }; - - await progress.pushToolProgress(line, { startImmediately: true }); - - expect(update).toHaveBeenCalledWith("Shelling\n\n🛠️ Exec: git status", { - flush: true, - lines: [line], - }); - }); - - it("keeps reasoning details hidden when tool progress lines are hidden", async () => { - const update = vi.fn(); - const progress = createChannelProgressDraftCompositor({ + it("gates window thinking on its own flag, independent of tool progress", async () => { + // thinking: false hides thoughts even though toolProgress stays on… + const hiddenUpdate = vi.fn(); + const hidden = createChannelProgressDraftCompositor({ entry: { - streaming: { mode: "progress", progress: { label: "Shelling", toolProgress: false } }, + streaming: { mode: "progress", progress: { label: "Shelling" } }, }, mode: "progress", active: true, seed: "test", + reasoningGate: false, + update: hiddenUpdate, + }); + await hidden.pushToolProgress("🛠️ Exec", { startImmediately: true }); + await hidden.pushReasoningProgress("Reading files"); + expect(hiddenUpdate.mock.calls.every(([text]) => !String(text).includes("Reading"))).toBe(true); + + const defaultUpdate = vi.fn(); + const sharedDefault = createChannelProgressDraftCompositor({ + entry: { + streaming: { + mode: "progress", + progress: { label: "Shelling", toolProgress: false }, + }, + }, + mode: "progress", + active: true, + seed: "test", + update: defaultUpdate, + }); + await sharedDefault.pushToolProgress("🛠️ Exec", { startImmediately: true }); + await sharedDefault.pushReasoningProgress("Reading files"); + expect(defaultUpdate.mock.calls.every(([text]) => !String(text).includes("Reading"))).toBe( + true, + ); + + const update = vi.fn(); + const progress = createChannelProgressDraftCompositor({ + entry: { + streaming: { + mode: "progress", + progress: { label: "Shelling", toolProgress: false }, + }, + }, + mode: "progress", + active: true, + seed: "test", + reasoningLinePrefix: "🧠 ", + reasoningGate: true, + update, + }); + await progress.pushToolProgress("🛠️ Exec", { startImmediately: true }); + await progress.pushReasoningProgress("Reading files"); + expect(update).toHaveBeenLastCalledWith("Shelling\n\n🧠 _Reading files_", { + lines: ["🧠 _Reading files_"], + }); + }); + + it("re-arms the draft for a queued turn after the primary final settled", async () => { + const update = vi.fn(); + const progress = createChannelProgressDraftCompositor({ + entry: { streaming: { mode: "progress", progress: { label: "Shelling" } } }, + mode: "progress", + active: true, + seed: "test", update, }); - await progress.pushToolProgress("🛠️ Exec", { startImmediately: true }); - await progress.pushReasoningProgress("Reading files"); + progress.markFinalReplyStarted(); + progress.markFinalReplyDelivered(); + expect(await progress.pushReasoningProgress("queued-turn thinking")).toBe(false); - expect(update).toHaveBeenCalledWith("Shelling", { flush: true, lines: [] }); - expect(update.mock.calls.every(([text]) => !String(text).includes("Reading"))).toBe(true); + // New assistant message boundary on a queued/followup turn. + progress.beginNewTurn(); + await progress.start(); + await progress.pushReasoningProgress("queued-turn thinking", { snapshot: true }); + + expect(update).toHaveBeenCalled(); }); it("does not resurrect progress after suppression", async () => { @@ -155,6 +124,7 @@ describe("createChannelProgressDraftCompositor", () => { mode: "progress", active: true, seed: "test", + reasoningLinePrefix: "🧠 ", update, }); @@ -162,34 +132,53 @@ describe("createChannelProgressDraftCompositor", () => { await progress.pushReasoningProgress("Reading"); await progress.pushReasoningProgress(" files"); - expect(update).toHaveBeenLastCalledWith( - "Shelling\n\n🛠️ Exec\n• _Reading files_", - expect.objectContaining({ - lines: ["🛠️ Exec", "_Reading files_"], - }), - ); + expect(update).toHaveBeenLastCalledWith("Shelling\n\n🛠️ Exec\n🧠 _Reading files_", { + lines: ["🛠️ Exec", "🧠 _Reading files_"], + }); }); - it("resets reasoning deltas without clearing tool progress", async () => { + it("labels window narration with a 💬 prefix", async () => { const update = vi.fn(); const progress = createChannelProgressDraftCompositor({ - entry: { streaming: { mode: "progress", progress: { label: "Shelling" } } }, + entry: { streaming: { mode: "progress", progress: { label: "Shelling", commentary: true } } }, mode: "progress", active: true, seed: "test", + commentaryLinePrefix: "💬 ", update, }); - await progress.pushToolProgress("🛠️ Exec", { startImmediately: true }); - await progress.pushReasoningProgress("Checking files"); - progress.resetReasoningProgress(); - await progress.pushReasoningProgress("Now testing"); + await progress.pushCommentaryProgress("Checking the workspace", { itemId: "c1" }); + + const rendered = update.mock.calls.map((call) => call[0]); + expect(rendered).toContain("Shelling\n\n💬 _Checking the workspace_"); + }); + + it("interleaves reasoning bursts with tool calls in arrival order", async () => { + const update = vi.fn(); + const progress = createChannelProgressDraftCompositor({ + entry: { + streaming: { mode: "progress", progress: { label: "Shelling", maxLines: 8 } }, + }, + mode: "progress", + active: true, + seed: "test", + reasoningLinePrefix: "🧠 ", + update, + }); + + // thought1 → tool1 → thought2 → tool2: each thought is its own line, + // appended in order, not collapsed into a single replaced line. + await progress.pushReasoningProgress("Listing the workspace"); + await progress.pushToolProgress("🛠️ ls", { startImmediately: true }); + await progress.pushReasoningProgress("Picking the largest"); + await progress.pushToolProgress("🛠️ wc", { startImmediately: true }); expect(update).toHaveBeenLastCalledWith( - "Shelling\n\n🛠️ Exec\n• _Now testing_", - expect.objectContaining({ - lines: ["🛠️ Exec", "_Now testing_"], - }), + "Shelling\n\n🧠 _Listing the workspace_\n🛠️ ls\n🧠 _Picking the largest_\n🛠️ wc", + { + lines: ["🧠 _Listing the workspace_", "🛠️ ls", "🧠 _Picking the largest_", "🛠️ wc"], + }, ); }); @@ -200,18 +189,16 @@ describe("createChannelProgressDraftCompositor", () => { mode: "progress", active: true, seed: "test", + reasoningLinePrefix: "🧠 ", update, }); await progress.pushToolProgress("🛠️ Exec", { startImmediately: true }); await progress.pushReasoningProgress("Checking filesFinal answer prose"); - expect(update).toHaveBeenLastCalledWith( - "Shelling\n\n🛠️ Exec\n• _Checking files_", - expect.objectContaining({ - lines: ["🛠️ Exec", "_Checking files_"], - }), - ); + expect(update).toHaveBeenLastCalledWith("Shelling\n\n🛠️ Exec\n🧠 _Checking files_", { + lines: ["🛠️ Exec", "🧠 _Checking files_"], + }); }); it("waits for complete reasoning tags before showing tagged progress", async () => { @@ -238,6 +225,7 @@ describe("createChannelProgressDraftCompositor", () => { mode: "progress", active: true, seed: "test", + reasoningLinePrefix: "🧠 ", update, }); @@ -245,12 +233,9 @@ describe("createChannelProgressDraftCompositor", () => { await progress.pushReasoningProgress("Checking filesFinal answer prose"); - expect(update).toHaveBeenLastCalledWith( - "Shelling\n\n🛠️ Exec\n• _Checking files_", - expect.objectContaining({ - lines: ["🛠️ Exec", "_Checking files_"], - }), - ); + expect(update).toHaveBeenLastCalledWith("Shelling\n\n🛠️ Exec\n🧠 _Checking files_", { + lines: ["🛠️ Exec", "🧠 _Checking files_"], + }); }); it("keeps literal reasoning tags inside code blocks", async () => { @@ -260,6 +245,7 @@ describe("createChannelProgressDraftCompositor", () => { mode: "progress", active: true, seed: "test", + reasoningLinePrefix: "🧠 ", update, }); @@ -267,10 +253,10 @@ describe("createChannelProgressDraftCompositor", () => { await progress.pushReasoningProgress("```html\nliteral\n```"); expect(update).toHaveBeenLastCalledWith( - "Shelling\n\n🛠️ Exec\n• _```html literal ```_", - expect.objectContaining({ - lines: ["🛠️ Exec", "_```html literal ```_"], - }), + "Shelling\n\n🛠️ Exec\n🧠 _```html literal ```_", + { + lines: ["🛠️ Exec", "🧠 _```html literal ```_"], + }, ); }); @@ -281,6 +267,7 @@ describe("createChannelProgressDraftCompositor", () => { mode: "progress", active: true, seed: "test", + reasoningLinePrefix: "🧠 ", update, }); @@ -288,20 +275,9 @@ describe("createChannelProgressDraftCompositor", () => { await progress.pushReasoningProgress("Thinking\n\n_Reading_"); await progress.pushReasoningProgress("Thinking\n\n_Reading files_"); - expect(update).toHaveBeenLastCalledWith( - "Shelling\n\n🛠️ Exec\n• _Reading files_", - expect.objectContaining({ - lines: ["🛠️ Exec", "_Reading files_"], - }), - ); - }); - - it("preserves repeated plain progress lines when separated by another event", () => { - const lines = mergeChannelProgressDraftLine(["Starting", "Finished"], "Starting", { - maxLines: 4, + expect(update).toHaveBeenLastCalledWith("Shelling\n\n🛠️ Exec\n🧠 _Reading files_", { + lines: ["🛠️ Exec", "🧠 _Reading files_"], }); - - expect(lines).toEqual(["Starting", "Finished", "Starting"]); }); it("logs a timer-fired start failure via the gate's default boundary logger", async () => { diff --git a/src/channels/progress-draft-compositor.ts b/src/channels/progress-draft-compositor.ts index 5d48cd8846ee..e429c78e5cbe 100644 --- a/src/channels/progress-draft-compositor.ts +++ b/src/channels/progress-draft-compositor.ts @@ -45,11 +45,25 @@ export function createChannelProgressDraftCompositor(params: { formatLine?: (line: string) => string; isEmptyLine?: (line: ChannelProgressDraftCompositorLine | undefined) => boolean; shouldStartNow?: (line: ChannelProgressDraftCompositorLine | undefined) => boolean; + reasoningLinePrefix?: string; + commentaryLinePrefix?: string; + reasoningGate?: boolean; + commentaryItalics?: boolean; }) { + const reasoningLinePrefix = params.reasoningLinePrefix ?? ""; + const commentaryLinePrefix = params.commentaryLinePrefix ?? ""; + const commentaryItalics = params.commentaryItalics ?? true; + const stripLaneItalics = (text: string): string => + text + .split("\n") + .map((line) => line.replace(/^_(.*)_$/su, "$1")) + .join("\n"); const previewToolProgressEnabled = params.active && resolveChannelStreamingPreviewToolProgress(params.entry); const commentaryProgressEnabled = params.active && resolveChannelStreamingProgressCommentary(params.entry); + const thinkingProgressEnabled = + params.active && (params.reasoningGate ?? previewToolProgressEnabled); const suppressDefaultToolProgressMessages = params.active && resolveChannelStreamingSuppressDefaultToolProgressMessages(params.entry, { @@ -147,6 +161,13 @@ export function createChannelProgressDraftCompositor(params: { if (shouldStoreLine && nextLines === lines) { return false; } + // A work line lands between reasoning bursts: commit the current thinking + // line so the next thought appends as its own line, interleaved with tools + // in arrival order, instead of replacing the prior thought. + if (shouldStoreLine) { + reasoningRawText = ""; + lastReasoningLine = undefined; + } if (shouldStoreLine && params.tryNativeUpdate) { // Native draft updates get unformatted text; if the channel accepts it, // keep local state aligned without sending a generic draft message. @@ -201,11 +222,20 @@ export function createChannelProgressDraftCompositor(params: { }, markFinalReplyStarted() { finalReplyStarted = true; - gate.cancel(); }, markFinalReplyDelivered() { finalReplyDelivered = true; - gate.cancel(); + }, + // Queued/followup turns reuse this compositor after the primary turn's + // final reply settled it. Re-arm the draft lanes so the queued turn gets + // the same in-progress rendering as a primary turn (path independence). + beginNewTurn() { + if (!finalReplyStarted && !finalReplyDelivered) { + return; + } + finalReplyStarted = false; + finalReplyDelivered = false; + clearProgressState(false); }, reset() { clearProgressState(false); @@ -250,7 +280,8 @@ export function createChannelProgressDraftCompositor(params: { params.mode !== "progress" || !text || progressSuppressed || - finalReplyDelivered + finalReplyDelivered || + !thinkingProgressEnabled ) { return false; } @@ -261,26 +292,25 @@ export function createChannelProgressDraftCompositor(params: { if (!normalized) { return false; } - const displayLine = formatReasoningProgressDisplayLine( + const compactLine = formatReasoningProgressDisplayLine( normalized, resolveChannelProgressDraftMaxLineChars(params.entry), ); - if (!displayLine) { + if (!compactLine) { return false; } - if (previewToolProgressEnabled) { - // Reasoning streams usually arrive as deltas. Replace the previous - // reasoning line so the draft stays compact instead of appending noise. - const priorIndex = - lastReasoningLine === undefined ? -1 : lines.lastIndexOf(lastReasoningLine); - if (priorIndex >= 0) { - lines = [...lines]; - lines[priorIndex] = displayLine; - } else { - lines = [...lines, displayLine].slice(-resolveChannelProgressDraftMaxLines(params.entry)); - } - lastReasoningLine = displayLine; + const displayLine = `${reasoningLinePrefix}${compactLine}`; + // Reasoning streams usually arrive as deltas. Replace the previous + // reasoning line so the draft stays compact instead of appending noise. + const priorIndex = + lastReasoningLine === undefined ? -1 : lines.lastIndexOf(lastReasoningLine); + if (priorIndex >= 0) { + lines = [...lines]; + lines[priorIndex] = displayLine; + } else { + lines = [...lines, displayLine].slice(-resolveChannelProgressDraftMaxLines(params.entry)); } + lastReasoningLine = displayLine; const progressActive = await gate.noteWork(); if (progressActive && gate.hasStarted) { return await render(); @@ -310,8 +340,10 @@ export function createChannelProgressDraftCompositor(params: { } const line: ChannelProgressDraftLine = { id: lineId, + // The lane marker (💬, matching 🧠 thinking / 🛠️ tools) is a per-channel + // presentation choice supplied via commentaryLinePrefix; default none. + text: `${commentaryLinePrefix}${commentaryItalics ? normalized : stripLaneItalics(normalized)}`, kind: "item", - text: normalized, label: "Commentary", prefix: false, }; diff --git a/src/config/types.discord.ts b/src/config/types.discord.ts index 73cb5d4dc75e..1206261d8a03 100644 --- a/src/config/types.discord.ts +++ b/src/config/types.discord.ts @@ -1,6 +1,7 @@ // Defines Discord channel configuration types. import type { ChannelPreviewStreamingConfig, + ChannelStreamingProgressConfig, ContextVisibilityMode, DmPolicy, GroupPolicy, @@ -22,7 +23,9 @@ import type { GroupToolPolicyBySenderConfig, GroupToolPolicyConfig } from "./typ import type { TtsConfig } from "./types.tts.js"; export type DiscordStreamMode = "off" | "partial" | "block" | "progress"; -export type DiscordChannelStreamingConfig = ChannelPreviewStreamingConfig; +export type DiscordChannelStreamingConfig = Omit & { + progress?: ChannelStreamingProgressConfig; +}; export type DiscordPluralKitConfig = { enabled?: boolean; diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index e60ab035f354..f85eac1fde84 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -103,6 +103,7 @@ const ChannelStreamingProgressSchema = z commentary: z.boolean().optional(), }) .strict(); +const DiscordStreamingProgressSchema = ChannelStreamingProgressSchema; const SlackStreamingProgressSchema = ChannelStreamingProgressSchema.extend({ nativeTaskCards: z.boolean().optional(), }).strict(); @@ -125,7 +126,9 @@ const ChannelPreviewStreamingConfigSchema = z const TelegramPreviewStreamingConfigSchema = ChannelPreviewStreamingConfigSchema.extend({ preview: ChannelStreamingPreviewSchema.optional(), }).strict(); -const DiscordPreviewStreamingConfigSchema = ChannelPreviewStreamingConfigSchema; +const DiscordPreviewStreamingConfigSchema = ChannelPreviewStreamingConfigSchema.extend({ + progress: DiscordStreamingProgressSchema.optional(), +}).strict(); const SlackStreamingConfigSchema = ChannelPreviewStreamingConfigSchema.extend({ nativeTransport: z.boolean().optional(), progress: SlackStreamingProgressSchema.optional(), diff --git a/src/plugin-sdk/reply-payload.ts b/src/plugin-sdk/reply-payload.ts index 883ac91eaa09..985817782056 100644 --- a/src/plugin-sdk/reply-payload.ts +++ b/src/plugin-sdk/reply-payload.ts @@ -50,6 +50,8 @@ export type ReasoningReplyPayload = { text?: string; /** Explicit reasoning flag from upstream payload producers. */ isReasoning?: boolean; + /** Marks pre-tool commentary (💬) — a display lane, suppressed unless the channel opts in. */ + isCommentary?: boolean; }; /** Derived sendability facts for text/media outbound payload delivery. */ diff --git a/src/talk/agent-consult-tool.ts b/src/talk/agent-consult-tool.ts index 17aa57080d08..0679c7ae554c 100644 --- a/src/talk/agent-consult-tool.ts +++ b/src/talk/agent-consult-tool.ts @@ -222,12 +222,12 @@ export function buildRealtimeVoiceAgentConsultPrompt(params: { /** Collect only visible answer text from streamed delegated-agent payloads. */ export function collectRealtimeVoiceAgentConsultVisibleText( - payloads: Array<{ text?: unknown; isError?: boolean; isReasoning?: boolean }>, + payloads: Array<{ text?: unknown; isError?: boolean; isReasoning?: boolean; isCommentary?: boolean }>, ): string | null { const chunks: string[] = []; for (const payload of payloads) { // Spoken replies must not include hidden reasoning or error-channel text. - if (payload.isError || payload.isReasoning) { + if (payload.isError || payload.isReasoning || payload.isCommentary) { continue; } const text = normalizeOptionalString(payload.text); From 339d19b089c591cd1a2b3a8ca895299f69bce199 Mon Sep 17 00:00:00 2001 From: Daniel Nylander Date: Wed, 1 Jul 2026 03:15:04 +0200 Subject: [PATCH 20/29] Add Swedish mobile app localization (#98043) * feat: add Swedish mobile app localization * fix: remove dead Swedish watch extension localization * docs: document iOS metadata locale directories --------- Co-authored-by: Daniel Nylander --- .../app/src/main/res/values-sv/strings.xml | 3 +++ .../ActivityWidget/sv.lproj/InfoPlist.strings | 1 + .../ShareExtension/sv.lproj/InfoPlist.strings | 1 + apps/ios/Sources/sv.lproj/InfoPlist.strings | 14 ++++++++++++++ apps/ios/WatchApp/sv.lproj/InfoPlist.strings | 1 + apps/ios/fastlane/metadata/README.md | 2 +- .../fastlane/metadata/sv-SE/description.txt | 19 +++++++++++++++++++ apps/ios/fastlane/metadata/sv-SE/keywords.txt | 1 + .../fastlane/metadata/sv-SE/marketing_url.txt | 1 + apps/ios/fastlane/metadata/sv-SE/name.txt | 1 + .../fastlane/metadata/sv-SE/privacy_url.txt | 1 + .../metadata/sv-SE/promotional_text.txt | 1 + .../fastlane/metadata/sv-SE/release_notes.txt | 3 +++ apps/ios/fastlane/metadata/sv-SE/subtitle.txt | 1 + .../fastlane/metadata/sv-SE/support_url.txt | 1 + 15 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 apps/android/app/src/main/res/values-sv/strings.xml create mode 100644 apps/ios/ActivityWidget/sv.lproj/InfoPlist.strings create mode 100644 apps/ios/ShareExtension/sv.lproj/InfoPlist.strings create mode 100644 apps/ios/Sources/sv.lproj/InfoPlist.strings create mode 100644 apps/ios/WatchApp/sv.lproj/InfoPlist.strings create mode 100644 apps/ios/fastlane/metadata/sv-SE/description.txt create mode 100644 apps/ios/fastlane/metadata/sv-SE/keywords.txt create mode 100644 apps/ios/fastlane/metadata/sv-SE/marketing_url.txt create mode 100644 apps/ios/fastlane/metadata/sv-SE/name.txt create mode 100644 apps/ios/fastlane/metadata/sv-SE/privacy_url.txt create mode 100644 apps/ios/fastlane/metadata/sv-SE/promotional_text.txt create mode 100644 apps/ios/fastlane/metadata/sv-SE/release_notes.txt create mode 100644 apps/ios/fastlane/metadata/sv-SE/subtitle.txt create mode 100644 apps/ios/fastlane/metadata/sv-SE/support_url.txt diff --git a/apps/android/app/src/main/res/values-sv/strings.xml b/apps/android/app/src/main/res/values-sv/strings.xml new file mode 100644 index 000000000000..0c813318613f --- /dev/null +++ b/apps/android/app/src/main/res/values-sv/strings.xml @@ -0,0 +1,3 @@ + + OpenClaw-nod + diff --git a/apps/ios/ActivityWidget/sv.lproj/InfoPlist.strings b/apps/ios/ActivityWidget/sv.lproj/InfoPlist.strings new file mode 100644 index 000000000000..ba313eae64d3 --- /dev/null +++ b/apps/ios/ActivityWidget/sv.lproj/InfoPlist.strings @@ -0,0 +1 @@ +"CFBundleDisplayName" = "OpenClaw-aktivitet"; diff --git a/apps/ios/ShareExtension/sv.lproj/InfoPlist.strings b/apps/ios/ShareExtension/sv.lproj/InfoPlist.strings new file mode 100644 index 000000000000..e41b5d7b859b --- /dev/null +++ b/apps/ios/ShareExtension/sv.lproj/InfoPlist.strings @@ -0,0 +1 @@ +"CFBundleDisplayName" = "Dela till OpenClaw"; diff --git a/apps/ios/Sources/sv.lproj/InfoPlist.strings b/apps/ios/Sources/sv.lproj/InfoPlist.strings new file mode 100644 index 000000000000..04be506cf3a6 --- /dev/null +++ b/apps/ios/Sources/sv.lproj/InfoPlist.strings @@ -0,0 +1,14 @@ +"CFBundleDisplayName" = "OpenClaw"; +"NSLocalNetworkUsageDescription" = "OpenClaw hittar och ansluter till din OpenClaw Gateway i det lokala nätverket."; +"NSCameraUsageDescription" = "OpenClaw kan ta bilder eller korta videoklipp när du begär det via gatewayen."; +"NSCalendarsUsageDescription" = "OpenClaw använder dina kalendrar för att visa händelser och schemainformation när du aktiverar kalenderåtkomst."; +"NSCalendarsFullAccessUsageDescription" = "OpenClaw använder dina kalendrar för att visa händelser och schemainformation när du aktiverar kalenderåtkomst."; +"NSCalendarsWriteOnlyAccessUsageDescription" = "OpenClaw använder dina kalendrar för att lägga till händelser när du aktiverar kalenderåtkomst."; +"NSContactsUsageDescription" = "OpenClaw använder dina kontakter så att du kan söka efter och hänvisa till personer när du använder assistenten."; +"NSLocationWhenInUseUsageDescription" = "OpenClaw använder din plats när du tillåter platsdelning."; +"NSLocationAlwaysAndWhenInUseUsageDescription" = "OpenClaw kan dela din plats i bakgrunden när du aktiverar Alltid."; +"NSMicrophoneUsageDescription" = "OpenClaw använder mikrofonen för realtidschatt, röstaktivering och tryck-och-prata."; +"NSMotionUsageDescription" = "OpenClaw kan använda rörelsedata för att stödja enhetsmedvetna interaktioner och automatiseringar."; +"NSPhotoLibraryUsageDescription" = "OpenClaw behöver åtkomst till bildbiblioteket när du väljer befintliga bilder att dela med din assistent."; +"NSRemindersFullAccessUsageDescription" = "OpenClaw använder dina påminnelser för att lista, lägga till och slutföra uppgifter när du aktiverar åtkomst till påminnelser."; +"NSSpeechRecognitionUsageDescription" = "OpenClaw använder taligenkänning på enheten för Talk-läge och röstaktivering."; diff --git a/apps/ios/WatchApp/sv.lproj/InfoPlist.strings b/apps/ios/WatchApp/sv.lproj/InfoPlist.strings new file mode 100644 index 000000000000..7bdef9320492 --- /dev/null +++ b/apps/ios/WatchApp/sv.lproj/InfoPlist.strings @@ -0,0 +1 @@ +"CFBundleDisplayName" = "OpenClaw"; diff --git a/apps/ios/fastlane/metadata/README.md b/apps/ios/fastlane/metadata/README.md index 79e5151ce291..98e1bc27738b 100644 --- a/apps/ios/fastlane/metadata/README.md +++ b/apps/ios/fastlane/metadata/README.md @@ -44,7 +44,7 @@ Or set `APP_STORE_CONNECT_API_KEY_PATH`. ## Notes -- Locale files live under `metadata/en-US/`. +- Locale files live under `metadata//`, for example `metadata/en-US/` and `metadata/sv-SE/`. Each locale directory should use the public metadata filenames consumed by the `ios metadata` lane. - `release_notes.txt` is generated from `apps/ios/CHANGELOG.md`; after changelog updates, run `pnpm ios:version:sync`. - `apps/ios/APP-REVIEW-NOTES.md` is rendered to `apps/ios/build/app-review/APP-REVIEW-NOTES.pdf` and uploaded as the App Review attachment when metadata is uploaded. - Release notes resolve from `## ` first, then fall back to `## Unreleased` while a TestFlight train is still in progress. diff --git a/apps/ios/fastlane/metadata/sv-SE/description.txt b/apps/ios/fastlane/metadata/sv-SE/description.txt new file mode 100644 index 000000000000..4745d5cc6fc8 --- /dev/null +++ b/apps/ios/fastlane/metadata/sv-SE/description.txt @@ -0,0 +1,19 @@ +OpenClaw är en personlig AI-assistent som du kör på dina egna enheter. + +Parkoppla iOS-appen med din OpenClaw Gateway och använd din iPhone som en säker nod för chatt, röst, godkännanden, delning och enhetsmedveten automatisering. + +Det här kan du göra: +- Parkoppla med din privata OpenClaw Gateway via QR-kod eller installationskod +- Chatta med din assistent från iPhone +- Använd Talk-läge i realtid och tryck-och-prata +- Granska åtgärder som väntar på godkännande från gatewayen +- Dela text, länkar och media direkt från iOS till OpenClaw +- Aktivera enhetsfunktioner som kamera, skärm, plats, bilder, kontakter, kalender och påminnelser när du vill +- Ta emot pushväckningar och nodstatus för anslutna arbetsflöden + +OpenClaw är byggt för lokal kontroll: du styr din gateway, dina nycklar, din konfiguration och dina behörigheter. Åtkomst till enheten hanteras av iOS-behörigheter och aktiveras bara för de funktioner du vill använda. + +Kom igång: +1. Konfigurera din OpenClaw Gateway +2. Öppna iOS-appen och parkoppla den med gatewayen +3. Börja använda chatt, Talk-läge, godkännanden och automatiseringar från din iPhone diff --git a/apps/ios/fastlane/metadata/sv-SE/keywords.txt b/apps/ios/fastlane/metadata/sv-SE/keywords.txt new file mode 100644 index 000000000000..618b4b174561 --- /dev/null +++ b/apps/ios/fastlane/metadata/sv-SE/keywords.txt @@ -0,0 +1 @@ +openclaw,ai-assistent,lokal ai,iphone ai,röstassistent,automatisering,gateway,chatt,agent diff --git a/apps/ios/fastlane/metadata/sv-SE/marketing_url.txt b/apps/ios/fastlane/metadata/sv-SE/marketing_url.txt new file mode 100644 index 000000000000..5760de806f84 --- /dev/null +++ b/apps/ios/fastlane/metadata/sv-SE/marketing_url.txt @@ -0,0 +1 @@ +https://openclaw.ai diff --git a/apps/ios/fastlane/metadata/sv-SE/name.txt b/apps/ios/fastlane/metadata/sv-SE/name.txt new file mode 100644 index 000000000000..ab5dcead1578 --- /dev/null +++ b/apps/ios/fastlane/metadata/sv-SE/name.txt @@ -0,0 +1 @@ +OpenClaw - iOS-klient diff --git a/apps/ios/fastlane/metadata/sv-SE/privacy_url.txt b/apps/ios/fastlane/metadata/sv-SE/privacy_url.txt new file mode 100644 index 000000000000..44207346064c --- /dev/null +++ b/apps/ios/fastlane/metadata/sv-SE/privacy_url.txt @@ -0,0 +1 @@ +https://openclaw.ai/privacy diff --git a/apps/ios/fastlane/metadata/sv-SE/promotional_text.txt b/apps/ios/fastlane/metadata/sv-SE/promotional_text.txt new file mode 100644 index 000000000000..8ec1e4ee33f8 --- /dev/null +++ b/apps/ios/fastlane/metadata/sv-SE/promotional_text.txt @@ -0,0 +1 @@ +Parkoppla din iPhone med OpenClaw Gateway för chatt, röst i realtid, godkännanden, enhetsfunktioner och privat automatisering. diff --git a/apps/ios/fastlane/metadata/sv-SE/release_notes.txt b/apps/ios/fastlane/metadata/sv-SE/release_notes.txt new file mode 100644 index 000000000000..8feaac5c5053 --- /dev/null +++ b/apps/ios/fastlane/metadata/sv-SE/release_notes.txt @@ -0,0 +1,3 @@ +OpenClaw finns nu för iPhone. + +Anslut till din OpenClaw Gateway för att chatta med din assistent, använda Talk-läge i realtid, granska godkännanden, dela innehåll från iOS och använda enhetsfunktioner som kamera, plats, skärm och aviseringar i dina privata automatiseringar. diff --git a/apps/ios/fastlane/metadata/sv-SE/subtitle.txt b/apps/ios/fastlane/metadata/sv-SE/subtitle.txt new file mode 100644 index 000000000000..de6c43483843 --- /dev/null +++ b/apps/ios/fastlane/metadata/sv-SE/subtitle.txt @@ -0,0 +1 @@ +Personlig AI på dina enheter diff --git a/apps/ios/fastlane/metadata/sv-SE/support_url.txt b/apps/ios/fastlane/metadata/sv-SE/support_url.txt new file mode 100644 index 000000000000..d9b967500039 --- /dev/null +++ b/apps/ios/fastlane/metadata/sv-SE/support_url.txt @@ -0,0 +1 @@ +https://docs.openclaw.ai/platforms/ios From 6e98477ee49890fa341e03fa8791491f25eef67b Mon Sep 17 00:00:00 2001 From: lsr911 Date: Wed, 1 Jul 2026 09:15:08 +0800 Subject: [PATCH 21/29] fix(sms): guard Twilio JSON.parse against malformed API response bodies (#97999) Wrap JSON.parse in parseTwilioListPayload and retrieveTwilioMessagingService with try/catch to prevent a malformed Twilio API response from throwing an unhandled SyntaxError. - parseTwilioListPayload: return [] on parse failure (fail-safe for phone number listing) - retrieveTwilioMessagingService: throw descriptive Error on parse failure Note: parseTwilioApiError and parseTwilioSuccessPayload already had try/catch guards (lines 85-97, 104-121). Signed-off-by: lsr911 Co-authored-by: Claude --- extensions/sms/src/twilio.test.ts | 27 +++++++ extensions/sms/src/twilio.ts | 14 +++- test/_proof_twilio_json_guard.mts | 115 ++++++++++++++++++++++++++++++ 3 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 test/_proof_twilio_json_guard.mts diff --git a/extensions/sms/src/twilio.test.ts b/extensions/sms/src/twilio.test.ts index dfe9f4431d75..2bc05c4104c3 100644 --- a/extensions/sms/src/twilio.test.ts +++ b/extensions/sms/src/twilio.test.ts @@ -554,6 +554,33 @@ describe("Twilio SMS helpers", () => { expect(release).toHaveBeenCalledTimes(1); }); + it("rejects malformed JSON from Twilio Messaging Service lookup", async () => { + const fetchImpl = vi.fn( + async () => new Response("NOT JSON {{{", { status: 200, headers: { "content-type": "application/json" } }), + ); + + await expect( + retrieveTwilioMessagingService({ + account: createAccount({ messagingServiceSid: "MG123", fromNumber: "" }), + serviceSid: "MG123", + fetchImpl, + }), + ).rejects.toThrow("Twilio Messaging Service lookup returned malformed JSON."); + }); + + it("returns empty list on malformed JSON from Twilio incoming phone number list", async () => { + const fetchImpl = vi.fn( + async () => new Response("NOT JSON {{{", { status: 200, headers: { "content-type": "application/json" } }), + ); + + const result = await listTwilioIncomingPhoneNumbers({ + account: createAccount(), + fetchImpl, + }); + + expect(result).toEqual([]); + }); + it("bounds and cancels oversized guarded Twilio success bodies", async () => { const release = vi.fn(async () => {}); const tracked = cancelTrackedTextResponse("x".repeat(1024 * 1024 + 1), { status: 201 }); diff --git a/extensions/sms/src/twilio.ts b/extensions/sms/src/twilio.ts index df26ebc6ece2..ecedbebf801b 100644 --- a/extensions/sms/src/twilio.ts +++ b/extensions/sms/src/twilio.ts @@ -424,7 +424,12 @@ function parseTwilioListPayload( if (!text.trim()) { return []; } - const parsed: unknown = JSON.parse(text); + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return []; + } if (!parsed || typeof parsed !== "object") { return []; } @@ -482,7 +487,12 @@ export async function retrieveTwilioMessagingService(params: { if (!response.ok) { throw new TwilioSmsApiError(response.status, response.text, "messaging-service lookup"); } - const parsed: unknown = JSON.parse(response.text); + let parsed: unknown; + try { + parsed = JSON.parse(response.text); + } catch { + throw new Error("Twilio Messaging Service lookup returned malformed JSON."); + } if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { throw new Error("Twilio Messaging Service lookup returned malformed JSON."); } diff --git a/test/_proof_twilio_json_guard.mts b/test/_proof_twilio_json_guard.mts new file mode 100644 index 000000000000..f15a9eff8324 --- /dev/null +++ b/test/_proof_twilio_json_guard.mts @@ -0,0 +1,115 @@ +/** + * Real behavior proof: Twilio malformed JSON → graceful handling. + * + * Calls listTwilioIncomingPhoneNumbers and retrieveTwilioMessagingService + * with a custom fetchImpl that returns malformed JSON, exercising the actual + * changed try/catch guards in parseTwilioListPayload and + * retrieveTwilioMessagingService. No vitest or vi.mock required. + * + * Usage: node --import tsx test/_proof_twilio_json_guard.mts + */ + +let pass = 0; +let fail = 0; + +function check(label: string, ok: boolean, detail = "") { + if (ok) { pass++; console.log(`PASS ${label}${detail ? ` :: ${detail}` : ""}`); } + else { fail++; console.error(`FAIL ${label}${detail ? ` :: ${detail}` : ""}`); } +} + +async function proofListPayloadMalformed() { + const { listTwilioIncomingPhoneNumbers } = await import( + "../extensions/sms/src/twilio.js" + ); + + // Custom fetchImpl returning malformed JSON + const badFetch = async () => + new Response("NOT JSON {{{", { + status: 200, + headers: { "content-type": "application/json" }, + }); + + const result = await listTwilioIncomingPhoneNumbers({ + account: { accountSid: "AC-proof", authToken: "proof-token", fromNumber: "+15550001111" }, + fetchImpl: badFetch as typeof fetch, + }); + + check("listPayload malformed: returns [] (fail-safe)", + Array.isArray(result) && result.length === 0, + `result=${JSON.stringify(result)}`); +} + +async function proofMessagingServiceMalformed() { + const { retrieveTwilioMessagingService } = await import( + "../extensions/sms/src/twilio.js" + ); + + const badFetch = async () => + new Response("NOT JSON {{{", { + status: 200, + headers: { "content-type": "application/json" }, + }); + + let error: unknown; + try { + await retrieveTwilioMessagingService({ + account: { accountSid: "AC-proof", authToken: "proof-token", fromNumber: "+15550001111" }, + serviceSid: "MG-proof", + fetchImpl: badFetch as typeof fetch, + }); + check("messagingService malformed: throws", false, "should have thrown"); + } catch (err: unknown) { + check("messagingService malformed: throws Error", err instanceof Error, + `type=${err instanceof Error ? err.constructor.name : String(err)}`); + if (err instanceof Error) { + check("messagingService malformed: mentions malformed JSON", + err.message.includes("malformed JSON"), + `msg="${err.message}"`); + } + } +} + +async function proofListPayloadValid() { + const { listTwilioIncomingPhoneNumbers } = await import( + "../extensions/sms/src/twilio.js" + ); + + const goodFetch = async () => + new Response( + JSON.stringify({ + incoming_phone_numbers: [ + { + sid: "PN123", + phone_number: "+15550009999", + friendly_name: "Test", + sms_url: "https://example.com/webhook", + sms_method: "POST", + capabilities: { sms: true, mms: false }, + }, + ], + page: 0, + page_size: 50, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + + const result = await listTwilioIncomingPhoneNumbers({ + account: { accountSid: "AC-proof", authToken: "proof-token", fromNumber: "+15550001111" }, + fetchImpl: goodFetch as typeof fetch, + }); + + check("listPayload valid: parsed correctly", + result.length === 1 && result[0]?.sid === "PN123", + `count=${result.length} sid=${result[0]?.sid}`); +} + +async function main() { + console.log(`node --import tsx test/_proof_twilio_json_guard.mts\n`); + await proofListPayloadMalformed(); + await proofMessagingServiceMalformed(); + await proofListPayloadValid(); + console.log(`\n[proof] ${pass} PASS, ${fail} FAIL`); + if (fail > 0) process.exit(1); +} + +main(); From 853a274f14dde8bc00ccca5e74b9affec6e5c20d Mon Sep 17 00:00:00 2001 From: lsr911 Date: Wed, 1 Jul 2026 09:15:14 +0800 Subject: [PATCH 22/29] fix(matrix): guard JSON.parse against malformed homeserver response bodies (#97973) Wrap JSON.parse(text) in MatrixAuthedHttpClient.requestJson with try/catch to prevent a malformed Matrix homeserver response from throwing an unhandled SyntaxError. On parse failure, throw an Error with statusCode attached (matching the buildHttpError convention) so callers can handle it like any other Matrix API error. Signed-off-by: lsr911 Co-authored-by: Claude --- .../matrix/src/matrix/sdk/http-client.test.ts | 29 +++++++++++++++++++ .../matrix/src/matrix/sdk/http-client.ts | 9 +++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/extensions/matrix/src/matrix/sdk/http-client.test.ts b/extensions/matrix/src/matrix/sdk/http-client.test.ts index ac33a024f2ad..38506a7b7da8 100644 --- a/extensions/matrix/src/matrix/sdk/http-client.test.ts +++ b/extensions/matrix/src/matrix/sdk/http-client.test.ts @@ -140,4 +140,33 @@ describe("MatrixAuthedHttpClient", () => { expect(httpError.message).toBe("forbidden"); expect(httpError.statusCode).toBe(403); }); + + it("throws descriptive error on malformed JSON success response", async () => { + performMatrixRequestMock.mockResolvedValue({ + response: new Response("NOT JSON {{{", { + status: 200, + headers: { "content-type": "application/json" }, + }), + text: "NOT JSON {{{", + buffer: Buffer.from("NOT JSON {{{", "utf8"), + }); + + const client = new MatrixAuthedHttpClient({ + homeserver: "https://matrix.example.org", + accessToken: "token", + }); + let rejection: unknown; + try { + await client.requestJson({ + method: "GET", + endpoint: "/_matrix/client/v3/sync", + timeoutMs: 5000, + }); + } catch (error) { + rejection = error; + } + + expect(rejection).toBeInstanceOf(Error); + expect((rejection as Error).message).toContain("malformed JSON"); + }); }); diff --git a/extensions/matrix/src/matrix/sdk/http-client.ts b/extensions/matrix/src/matrix/sdk/http-client.ts index d08f7fa3ddcb..4d345f390bc1 100644 --- a/extensions/matrix/src/matrix/sdk/http-client.ts +++ b/extensions/matrix/src/matrix/sdk/http-client.ts @@ -52,7 +52,14 @@ export class MatrixAuthedHttpClient { if (!text.trim()) { return {}; } - return JSON.parse(text); + try { + return JSON.parse(text); + } catch { + throw Object.assign( + new Error("Matrix homeserver returned malformed JSON"), + { statusCode: response.status }, + ); + } } return text; } From 8028269d32875905d60ead1b37191e274961b9f5 Mon Sep 17 00:00:00 2001 From: wendy <31603741+wendy-chsy@users.noreply.github.com> Date: Wed, 1 Jul 2026 09:15:19 +0800 Subject: [PATCH 23/29] fix(core): propagate caller env PATHEXT through isExecutableFile on Windows (#98093) * fix(core): propagate caller env PATHEXT through isExecutableFile on Windows isExecutableFile hardcoded undefined when calling resolveWindowsExecutableExtSet, ignoring any caller-provided custom env.PATHEXT. This meant resolveExecutablePath and resolveExecutableFromPathEnv would fall back to process.env.PATHEXT even when the caller supplied a different env with an extended PATHEXT (e.g. .PS1). - Add optional options.env parameter to isExecutableFile - resolveWindowsExecutableExtSet now reads from options?.env - All 3 callers pass their available env through Affects Windows deployments using sandbox/container environments where PATHEXT differs from process.env (Docker Windows containers, CI runners, tests). Fully backward compatible: undefined env falls back to process.env.PATHEXT. * fix(core): also propagate caller env PATHEXT in node-host invoke resolver - Fix sibling system.which resolver in src/node-host/invoke.ts:378 to use caller env PATHEXT instead of process.env only - Add comprehensive Windows-mocked tests for caller env PATHEXT propagation through isExecutableFile, resolveExecutableFromPathEnv, and resolveExecutablePath - Tests cover: custom env accepted, fallback to process.env, path-separator and PATH-based resolution paths * fix(core): also propagate caller env PATHEXT through node-host invoke resolver - Add env?.PathExt and process.env.PathExt casing to both resolveWindowsExecutableExtSet and resolveWindowsExecutableExtensions for compatibility with callers using PascalCase env keys - Isolate positive PATHEXT tests from process.env.PATHEXT by explicitly setting it to .TXT before each test, ensuring they prove caller env propagation rather than host env leak * fix(core): add env?.PathExt casing to node-host system.which resolver --------- Co-authored-by: wendy-chsy --- src/infra/executable-path.test.ts | 126 ++++++++++++++++++++++++++++++ src/infra/executable-path.ts | 14 ++-- src/node-host/invoke.ts | 9 ++- 3 files changed, 143 insertions(+), 6 deletions(-) diff --git a/src/infra/executable-path.test.ts b/src/infra/executable-path.test.ts index e59d8eb00862..b0768e413bcc 100644 --- a/src/infra/executable-path.test.ts +++ b/src/infra/executable-path.test.ts @@ -216,3 +216,129 @@ describe("resolveExecutable", () => { }); }); }); + +describe("caller env PATHEXT propagation", () => { + // These tests verify that isExecutableFile and its callers use the + // caller-provided env.PATHEXT (not just process.env.PATHEXT) on Windows. + // Regression for: isExecutableFile hardcoded undefined -> ignore caller env. + + it("isExecutableFile respects caller env.PATHEXT on Windows", async () => { + const orig = process.env.PATHEXT; + process.env.PATHEXT = ".TXT"; + try { + await withTempDir({ prefix: "openclaw-exec-path-" }, async (base) => { + const ps1File = path.join(base, "script.ps1"); + await fs.writeFile(ps1File, 'Write-Output "ok"\n', "utf8"); + + // On Windows with only .PS1 in caller env, isExecutableFile should accept it + withMockedPlatform("win32", () => { + expect(isExecutableFile(ps1File, { env: { PATHEXT: ".PS1" } })).toBe(true); + }); + }); + } finally { + restoreEnvValue("PATHEXT", orig); + } + }); + + it("isExecutableFile fallback to process.env when no caller env is given", async () => { + await withTempDir({ prefix: "openclaw-exec-path-" }, async (base) => { + const ps1File = path.join(base, "script.ps1"); + await fs.writeFile(ps1File, 'Write-Output "ok"\n', "utf8"); + + // Save current process.env.PATHEXT, set it to empty so no extension matches + const orig = process.env.PATHEXT; + process.env.PATHEXT = ".TXT"; + try { + withMockedPlatform("win32", () => { + // .PS1 not in process.env.PATHEXT (which is .TXT) + expect(isExecutableFile(ps1File)).toBe(false); + }); + } finally { + restoreEnvValue("PATHEXT", orig); + } + }); + }); + + it("resolveExecutableFromPathEnv uses caller env PATHEXT on Windows", async () => { + const orig = process.env.PATHEXT; + process.env.PATHEXT = ".TXT"; + try { + await withTempDir({ prefix: "openclaw-exec-path-" }, async (base) => { + const binDir = path.join(base, "bin"); + await fs.mkdir(binDir, { recursive: true }); + + const ps1Path = path.join(binDir, "tool.ps1"); + await fs.writeFile(ps1Path, 'Write-Output "ok"\n', "utf8"); + // On Windows the delimiter is ";", build pathEnv accordingly for mocked platform + const pathEnv = `${binDir};${process.env.PATH ?? ""}`; + + withMockedPlatform("win32", () => { + // Caller env has .PS1, process.env.PATHEXT does not + const result = resolveExecutableFromPathEnv("tool", pathEnv, { PATHEXT: ".PS1" }); + expect(result).toBe(ps1Path); + }); + }); + } finally { + restoreEnvValue("PATHEXT", orig); + } + }); + + it("resolveExecutablePath with path separator passes env to PATHEXT check on Windows", async () => { + const orig = process.env.PATHEXT; + process.env.PATHEXT = ".TXT"; + try { + await withTempDir({ prefix: "openclaw-exec-path-" }, async (base) => { + const ps1File = path.join(base, "script.ps1"); + await fs.writeFile(ps1File, 'Write-Output "ok"\n', "utf8"); + + withMockedPlatform("win32", () => { + // Passing an absolute path (has separator) with custom env + const result = resolveExecutablePath(ps1File, { env: { PATHEXT: ".PS1" } }); + expect(result).toBe(ps1File); + }); + }); + } finally { + restoreEnvValue("PATHEXT", orig); + } + }); + + it("resolveExecutablePath without path separator falls back to PATH env", async () => { + const orig = process.env.PATHEXT; + process.env.PATHEXT = ".TXT"; + try { + await withTempDir({ prefix: "openclaw-exec-path-" }, async (base) => { + const binDir = path.join(base, "bin"); + await fs.mkdir(binDir, { recursive: true }); + const ps1Path = path.join(binDir, "runner.ps1"); + await fs.writeFile(ps1Path, 'Write-Output "ok"\n', "utf8"); + + withMockedPlatform("win32", () => { + const result = resolveExecutablePath("runner", { + env: { PATH: binDir, PATHEXT: ".PS1" }, + }); + expect(result).toBe(ps1Path); + }); + }); + } finally { + restoreEnvValue("PATHEXT", orig); + } + }); + + it("resolveExecutablePath with path separator falls back to process.env when no caller env given", async () => { + await withTempDir({ prefix: "openclaw-exec-path-" }, async (base) => { + const ps1File = path.join(base, "script.ps1"); + await fs.writeFile(ps1File, 'Write-Output "ok"\n', "utf8"); + + const orig = process.env.PATHEXT; + process.env.PATHEXT = ".TXT"; + try { + withMockedPlatform("win32", () => { + // No caller env given, process.env.PATHEXT is .TXT -> .PS1 not matched -> undefined + expect(resolveExecutablePath(ps1File)).toBeUndefined(); + }); + } finally { + restoreEnvValue("PATHEXT", orig); + } + }); + }); +}); diff --git a/src/infra/executable-path.ts b/src/infra/executable-path.ts index 39407a860589..6b573d4811b4 100644 --- a/src/infra/executable-path.ts +++ b/src/infra/executable-path.ts @@ -46,8 +46,10 @@ function resolveWindowsExecutableExtensions( "", ...( env?.PATHEXT ?? + env?.PathExt ?? env?.Pathext ?? process.env.PATHEXT ?? + process.env.PathExt ?? process.env.Pathext ?? ".EXE;.CMD;.BAT;.COM" ) @@ -60,8 +62,10 @@ function resolveWindowsExecutableExtSet(env: NodeJS.ProcessEnv | undefined): Set return new Set( ( env?.PATHEXT ?? + env?.PathExt ?? env?.Pathext ?? process.env.PATHEXT ?? + process.env.PathExt ?? process.env.Pathext ?? ".EXE;.CMD;.BAT;.COM" ) @@ -71,7 +75,7 @@ function resolveWindowsExecutableExtSet(env: NodeJS.ProcessEnv | undefined): Set ); } -export function isExecutableFile(filePath: string): boolean { +export function isExecutableFile(filePath: string, options?: { env?: NodeJS.ProcessEnv }): boolean { try { const stat = fs.statSync(filePath); if (!stat.isFile()) { @@ -82,7 +86,7 @@ export function isExecutableFile(filePath: string): boolean { if (!ext) { return true; } - return resolveWindowsExecutableExtSet(undefined).has(ext); + return resolveWindowsExecutableExtSet(options?.env).has(ext); } fs.accessSync(filePath, fs.constants.X_OK); return true; @@ -102,7 +106,7 @@ export function resolveExecutableFromPathEnv( for (const entry of entries) { for (const ext of extensions) { const candidate = path.join(entry, executable + ext); - if (isExecutableFile(candidate)) { + if (isExecutableFile(candidate, { env })) { return candidate; } } @@ -119,7 +123,7 @@ export function resolveExecutablePath( return undefined; } if (candidate.includes("/") || candidate.includes("\\")) { - return isExecutableFile(candidate) ? candidate : undefined; + return isExecutableFile(candidate, options) ? candidate : undefined; } const envPath = options?.env?.PATH ?? options?.env?.Path ?? process.env.PATH ?? process.env.Path ?? ""; @@ -148,7 +152,7 @@ export function resolveExecutable(cmd: string): string { for (const entry of entries) { for (const ext of extensions) { const candidate = path.join(entry, cmd + ext); - if (isExecutableFile(candidate)) { + if (isExecutableFile(candidate, { env: process.env })) { matches.push(candidate); } } diff --git a/src/node-host/invoke.ts b/src/node-host/invoke.ts index b8bd59fae51f..5774cbf03007 100644 --- a/src/node-host/invoke.ts +++ b/src/node-host/invoke.ts @@ -375,7 +375,14 @@ function resolveExecutable(bin: string, env?: Record) { } const extensions = process.platform === "win32" - ? (process.env.PATHEXT ?? process.env.PathExt ?? ".EXE;.CMD;.BAT;.COM") + ? ( + env?.PATHEXT ?? + env?.PathExt ?? + env?.Pathext ?? + process.env.PATHEXT ?? + process.env.PathExt ?? + ".EXE;.CMD;.BAT;.COM" + ) .split(";") .map((ext) => normalizeLowercaseStringOrEmpty(ext)) : [""]; From 09ebc1d5668f3843fc48907a5bc9e740c04a52b0 Mon Sep 17 00:00:00 2001 From: zengLingbiao Date: Wed, 1 Jul 2026 09:15:24 +0800 Subject: [PATCH 24/29] test(utils): add unit tests for chunkItems (#98219) Add 8 test cases covering fixed-size array splitting, empty input, size <= 0, size=1, size > length, exact division, readonly input preservation, and fractional size behavior. --- src/utils/chunk-items.test.ts | 51 +++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 src/utils/chunk-items.test.ts diff --git a/src/utils/chunk-items.test.ts b/src/utils/chunk-items.test.ts new file mode 100644 index 000000000000..31bf515c6a9d --- /dev/null +++ b/src/utils/chunk-items.test.ts @@ -0,0 +1,51 @@ +// Chunk items tests cover fixed-size array splitting edge cases. +import { describe, expect, it } from "vitest"; +import { chunkItems } from "./chunk-items.js"; + +describe("chunkItems", () => { + it("splits items into fixed-size chunks", () => { + expect(chunkItems([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]); + expect(chunkItems(["a", "b", "c", "d"], 3)).toEqual([["a", "b", "c"], ["d"]]); + }); + + it("returns a single chunk when size is 0 or negative", () => { + expect(chunkItems([1, 2, 3], 0)).toEqual([[1, 2, 3]]); + expect(chunkItems([1, 2, 3], -1)).toEqual([[1, 2, 3]]); + }); + + it("returns empty array for empty input", () => { + expect(chunkItems([], 5)).toEqual([]); + expect(chunkItems([], 0)).toEqual([[]]); + expect(chunkItems([], -1)).toEqual([[]]); + }); + + it("wraps each item when size is 1", () => { + expect(chunkItems([1, 2, 3], 1)).toEqual([[1], [2], [3]]); + }); + + it("returns a single chunk when size exceeds array length", () => { + expect(chunkItems([1, 2, 3], 10)).toEqual([[1, 2, 3]]); + }); + + it("exactly divides when size matches array length", () => { + expect(chunkItems([1, 2, 3], 3)).toEqual([[1, 2, 3]]); + }); + + it("preserves readonly input without mutation", () => { + const input: readonly number[] = [10, 20, 30, 40]; + const result = chunkItems(input, 2); + expect(result).toEqual([ + [10, 20], + [30, 40], + ]); + expect(input).toEqual([10, 20, 30, 40]); + }); + + it("handles fractional size using slice native truncation behavior", () => { + // slice() truncates non-integer args, so 2.5 → chunk boundaries at 0, 2.5, 5.0 + expect(chunkItems([1, 2, 3, 4, 5], 2.5)).toEqual([ + [1, 2], + [3, 4, 5], + ]); + }); +}); From 4a200e6ecf109b7330b88b45ed2d79eb2157e1ac Mon Sep 17 00:00:00 2001 From: solodmd Date: Wed, 1 Jul 2026 09:15:31 +0800 Subject: [PATCH 25/29] test(config): add unit tests for resolveExecCommandHighlighting (#98087) --- src/config/exec-command-highlighting.test.ts | 154 +++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 src/config/exec-command-highlighting.test.ts diff --git a/src/config/exec-command-highlighting.test.ts b/src/config/exec-command-highlighting.test.ts new file mode 100644 index 000000000000..e8746c6acc87 --- /dev/null +++ b/src/config/exec-command-highlighting.test.ts @@ -0,0 +1,154 @@ +// Verifies exec command highlighting resolution across global and agent-scoped config. +import { describe, expect, it } from "vitest"; +import { resolveExecCommandHighlighting } from "./exec-command-highlighting.js"; +import type { OpenClawConfig } from "./types.openclaw.js"; + +describe("resolveExecCommandHighlighting", () => { + it("defaults to false when no config is provided", () => { + expect(resolveExecCommandHighlighting({})).toBe(false); + }); + + it("defaults to false when config is null", () => { + expect(resolveExecCommandHighlighting({ config: null })).toBe(false); + }); + + it("reads global exec commandHighlighting", () => { + const config = { + tools: { exec: { commandHighlighting: true } }, + } satisfies OpenClawConfig; + + expect(resolveExecCommandHighlighting({ config })).toBe(true); + }); + + it("returns false when global exec commandHighlighting is disabled", () => { + const config = { + tools: { exec: { commandHighlighting: false } }, + } satisfies OpenClawConfig; + + expect(resolveExecCommandHighlighting({ config })).toBe(false); + }); + + it("agent-scoped true overrides global false", () => { + const config = { + agents: { + list: [ + { + id: "alpha", + tools: { exec: { commandHighlighting: true } }, + }, + ], + }, + tools: { exec: { commandHighlighting: false } }, + } satisfies OpenClawConfig; + + expect(resolveExecCommandHighlighting({ config, agentId: "alpha" })).toBe(true); + }); + + it("agent-scoped false overrides global true", () => { + const config = { + agents: { + list: [ + { + id: "alpha", + tools: { exec: { commandHighlighting: false } }, + }, + ], + }, + tools: { exec: { commandHighlighting: true } }, + } satisfies OpenClawConfig; + + expect(resolveExecCommandHighlighting({ config, agentId: "alpha" })).toBe(false); + }); + + it("agent without override falls back to global true", () => { + const config = { + agents: { + list: [{ id: "alpha" }], + }, + tools: { exec: { commandHighlighting: true } }, + } satisfies OpenClawConfig; + + expect(resolveExecCommandHighlighting({ config, agentId: "alpha" })).toBe(true); + }); + + it("agent without override falls back to global false", () => { + const config = { + agents: { + list: [{ id: "alpha" }], + }, + tools: { exec: { commandHighlighting: false } }, + } satisfies OpenClawConfig; + + expect(resolveExecCommandHighlighting({ config, agentId: "alpha" })).toBe(false); + }); + + it("agent with explicit true and no global returns true", () => { + const config = { + agents: { + list: [ + { + id: "alpha", + tools: { exec: { commandHighlighting: true } }, + }, + ], + }, + } satisfies OpenClawConfig; + + expect(resolveExecCommandHighlighting({ config, agentId: "alpha" })).toBe(true); + }); + + it("agent with explicit false and no global falls back to false", () => { + const config = { + agents: { + list: [ + { + id: "alpha", + tools: { exec: { commandHighlighting: false } }, + }, + ], + }, + } satisfies OpenClawConfig; + + expect(resolveExecCommandHighlighting({ config, agentId: "alpha" })).toBe(false); + }); + + it("falls back to global config when agent ID is not in the agent list", () => { + const config = { + tools: { exec: { commandHighlighting: true } }, + } satisfies OpenClawConfig; + + expect(resolveExecCommandHighlighting({ config, agentId: "nonexistent" })).toBe(true); + }); + + it("agent ID normalization matches agent list entries", () => { + // normalizeAgentId lowercases and trims the agent ID, so "ALPHA" should + // match an entry with id "alpha". + const config = { + agents: { + list: [ + { + id: "alpha", + tools: { exec: { commandHighlighting: true } }, + }, + ], + }, + } satisfies OpenClawConfig; + + expect(resolveExecCommandHighlighting({ config, agentId: "ALPHA" })).toBe(true); + }); + + it("unrelated agent ID does not affect the result", () => { + const config = { + agents: { + list: [ + { + id: "other", + tools: { exec: { commandHighlighting: true } }, + }, + ], + }, + } satisfies OpenClawConfig; + + expect(resolveExecCommandHighlighting({ config, agentId: "alpha" })).toBe(false); + }); +}); From 3669280b9aec4b22b11eddcff257ad9b5bd53610 Mon Sep 17 00:00:00 2001 From: dwc1997 <66739829+dwc1997@users.noreply.github.com> Date: Wed, 1 Jul 2026 09:15:38 +0800 Subject: [PATCH 26/29] test(infra): add unit tests for SQLite number normalization (#98009) * test(infra): add unit tests for SQLite number normalization * fix: remove undefined param test, not in function signature --- src/infra/sqlite-number.test.ts | 41 +++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 src/infra/sqlite-number.test.ts diff --git a/src/infra/sqlite-number.test.ts b/src/infra/sqlite-number.test.ts new file mode 100644 index 000000000000..28ee4d9cc7bf --- /dev/null +++ b/src/infra/sqlite-number.test.ts @@ -0,0 +1,41 @@ +// Tests for SQLite number normalization. +import { describe, expect, it } from "vitest"; +import { normalizeSqliteNumber } from "./sqlite-number.js"; + +describe("normalizeSqliteNumber", () => { + it("returns number value unchanged", () => { + expect(normalizeSqliteNumber(5)).toBe(5); + }); + + it("converts bigint to number", () => { + expect(normalizeSqliteNumber(BigInt(5))).toBe(5); + }); + + it("returns undefined for null", () => { + expect(normalizeSqliteNumber(null)).toBeUndefined(); + }); + + it("returns zero unchanged", () => { + expect(normalizeSqliteNumber(0)).toBe(0); + }); + + it("returns negative value unchanged", () => { + expect(normalizeSqliteNumber(-1)).toBe(-1); + }); + + it("returns NaN unchanged", () => { + expect(normalizeSqliteNumber(Number.NaN)).toBe(Number.NaN); + }); + + it("converts large bigint", () => { + expect(normalizeSqliteNumber(BigInt(Number.MAX_SAFE_INTEGER))).toBe(Number.MAX_SAFE_INTEGER); + }); + + it("converts zero bigint", () => { + expect(normalizeSqliteNumber(BigInt(0))).toBe(0); + }); + + it("converts negative bigint", () => { + expect(normalizeSqliteNumber(BigInt(-1))).toBe(-1); + }); +}); From 201eb9cd29a10b504d6b87ef4119b5c1b8976b8e Mon Sep 17 00:00:00 2001 From: ooiuuii Date: Wed, 1 Jul 2026 09:17:33 +0800 Subject: [PATCH 27/29] fix(gateway): iOS Talk treats SecretRef-backed API keys as missing (#98210) * fix(gateway): resolve Talk SecretRefs for scoped native clients * fix(gateway): constrain Talk secret materialization * fix(gateway): redact Talk source provider secrets * fix(gateway): satisfy Talk config lint * docs(gateway): clarify Talk secret config payload --------- Co-authored-by: joshavant <830519+joshavant@users.noreply.github.com> --- docs/gateway/protocol.md | 3 + src/gateway/server-methods/talk.test.ts | 320 ++++++++++++++++++++++++ src/gateway/server-methods/talk.ts | 134 ++++++++-- src/gateway/server.talk-config.test.ts | 11 +- 4 files changed, 442 insertions(+), 26 deletions(-) diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index e028e54ffd66..3b77d403035a 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -243,6 +243,9 @@ Common scopes: `talk.config` with `includeSecrets: true` requires `operator.talk.secrets` (or `operator.admin`). +When secrets are included, clients should read the active Talk provider +credential from `talk.resolved.config.apiKey`; `talk.providers..apiKey` +stays source-shaped and may be a SecretRef object or a redacted string. Plugin-registered gateway RPC methods may request their own operator scope, but reserved core admin prefixes (`config.*`, `exec.approvals.*`, `wizard.*`, diff --git a/src/gateway/server-methods/talk.test.ts b/src/gateway/server-methods/talk.test.ts index 607303852046..b8d5d528ab0e 100644 --- a/src/gateway/server-methods/talk.test.ts +++ b/src/gateway/server-methods/talk.test.ts @@ -516,6 +516,326 @@ describe("talk.config handler", () => { expectRecordFields(resolved, { provider: "acme" }); expectRecordFields(resolved?.config, { apiKey: "__OPENCLAW_REDACTED__" }); }); + + it("returns runtime-resolved Talk provider SecretRefs to authorized clients", async () => { + const sourceConfig = createTalkConfig({ + source: "env", + provider: "default", + id: "ACME_SPEECH_API_KEY", + }); + const runtimeConfig = createTalkConfig("runtime-resolved-talk-key"); + + mocks.getSpeechProvider.mockReturnValue(undefined); + mocks.readConfigFileSnapshot.mockResolvedValue({ + path: "/tmp/openclaw.json", + hash: "test-hash", + valid: true, + config: sourceConfig, + }); + + const respond = vi.fn(); + await talkHandlers["talk.config"]({ + req: { type: "req", id: "1", method: "talk.config" }, + params: { includeSecrets: true }, + client: { connect: { scopes: ["operator.talk.secrets"] } } as never, + isWebchatConnect: () => false, + respond: respond as never, + context: { getRuntimeConfig: () => runtimeConfig } as never, + }); + + const response = expectRespondOk(respond) as { config?: { talk?: Record } }; + const talkConfig = response.config?.talk; + expectRecordFields(talkConfig, { provider: "acme" }); + const providers = talkConfig?.providers as Record | undefined; + const providerConfig = expectRecordFields(providers?.acme, { voiceId: "stub-default-voice" }); + expectRecordFields(providerConfig.apiKey, { + source: "env", + provider: "default", + id: "ACME_SPEECH_API_KEY", + }); + const resolved = talkConfig?.resolved as Record | undefined; + expectRecordFields(resolved, { provider: "acme" }); + expectRecordFields(resolved?.config, { apiKey: "runtime-resolved-talk-key" }); + }); + + it("materializes only the active Talk provider apiKey for authorized clients", async () => { + const sourceConfig = { + talk: { + provider: "acme", + providers: { + acme: { + apiKey: { source: "env", provider: "default", id: "ACME_SPEECH_API_KEY" }, + voiceId: "active-voice", + }, + other: { + apiKey: { source: "env", provider: "default", id: "OTHER_SPEECH_API_KEY" }, + voiceId: "inactive-voice", + }, + }, + realtime: { + provider: "openai", + providers: { + openai: { + apiKey: { source: "env", provider: "default", id: "OPENAI_REALTIME_API_KEY" }, + voice: "cedar", + }, + }, + }, + }, + } as OpenClawConfig; + const runtimeConfig = { + talk: { + provider: "acme", + providers: { + acme: { + apiKey: "runtime-active-talk-key", + voiceId: "active-voice", + }, + other: { + apiKey: "runtime-inactive-talk-key", + voiceId: "inactive-voice", + }, + }, + realtime: { + provider: "openai", + providers: { + openai: { + apiKey: "runtime-realtime-key", + voice: "cedar", + }, + }, + }, + }, + } as OpenClawConfig; + + mocks.getSpeechProvider.mockReturnValue(undefined); + mocks.readConfigFileSnapshot.mockResolvedValue({ + path: "/tmp/openclaw.json", + hash: "test-hash", + valid: true, + config: sourceConfig, + }); + + const respond = vi.fn(); + await talkHandlers["talk.config"]({ + req: { type: "req", id: "1", method: "talk.config" }, + params: { includeSecrets: true }, + client: { connect: { scopes: ["operator.talk.secrets"] } } as never, + isWebchatConnect: () => false, + respond: respond as never, + context: { getRuntimeConfig: () => runtimeConfig } as never, + }); + + const response = expectRespondOk(respond) as { config?: { talk?: Record } }; + const talkConfig = response.config?.talk; + const providers = talkConfig?.providers as Record | undefined; + expectRecordFields((providers?.acme as Record | undefined)?.apiKey, { + source: "env", + provider: "default", + id: "ACME_SPEECH_API_KEY", + }); + expectRecordFields((providers?.other as Record | undefined)?.apiKey, { + source: "env", + provider: "default", + id: "OTHER_SPEECH_API_KEY", + }); + const realtime = talkConfig?.realtime as Record | undefined; + const realtimeProviders = realtime?.providers as Record | undefined; + expectRecordFields((realtimeProviders?.openai as Record | undefined)?.apiKey, { + source: "env", + provider: "default", + id: "OPENAI_REALTIME_API_KEY", + }); + const resolved = talkConfig?.resolved as Record | undefined; + expectRecordFields(resolved, { provider: "acme" }); + expectRecordFields(resolved?.config, { apiKey: "runtime-active-talk-key" }); + + const serialized = JSON.stringify(response); + expect(serialized).toContain("runtime-active-talk-key"); + expect(serialized).not.toContain("runtime-inactive-talk-key"); + expect(serialized).not.toContain("runtime-realtime-key"); + }); + + it("does not expose resolver-returned secret-like fields beyond apiKey", async () => { + const sourceConfig = createTalkConfig({ + source: "env", + provider: "default", + id: "ACME_SPEECH_API_KEY", + }); + const runtimeConfig = createTalkConfig("runtime-resolved-talk-key"); + + mocks.getSpeechProvider.mockReturnValue({ + id: "acme", + label: "Acme Speech", + resolveTalkConfig: ({ + talkProviderConfig, + }: { + talkProviderConfig: Record; + }) => ({ + ...talkProviderConfig, + voiceId: "resolver-voice", + clientSecret: "resolver-client-secret", + authToken: "resolver-auth-token", + }), + }); + mocks.readConfigFileSnapshot.mockResolvedValue({ + path: "/tmp/openclaw.json", + hash: "test-hash", + valid: true, + config: sourceConfig, + }); + + const respond = vi.fn(); + await talkHandlers["talk.config"]({ + req: { type: "req", id: "1", method: "talk.config" }, + params: { includeSecrets: true }, + client: { connect: { scopes: ["operator.talk.secrets"] } } as never, + isWebchatConnect: () => false, + respond: respond as never, + context: { getRuntimeConfig: () => runtimeConfig } as never, + }); + + const response = expectRespondOk(respond) as { config?: { talk?: Record } }; + const resolved = response.config?.talk?.resolved as Record | undefined; + expectRecordFields(resolved?.config, { + apiKey: "runtime-resolved-talk-key", + voiceId: "resolver-voice", + clientSecret: "__OPENCLAW_REDACTED__", + authToken: "__OPENCLAW_REDACTED__", + }); + const serialized = JSON.stringify(response); + expect(serialized).not.toContain("resolver-client-secret"); + expect(serialized).not.toContain("resolver-auth-token"); + }); + + it("does not expose source provider raw keys or secret-like sibling fields", async () => { + const sourceConfig = { + talk: { + provider: "acme", + providers: { + acme: { + apiKey: "source-active-talk-key", + voiceId: "active-voice", + clientSecret: "source-client-secret", + }, + other: { + apiKey: "source-inactive-talk-key", + voiceId: "inactive-voice", + }, + }, + realtime: { + provider: "openai", + providers: { + openai: { + apiKey: "source-realtime-key", + authToken: "source-realtime-auth-token", + }, + }, + }, + }, + } as OpenClawConfig; + const runtimeConfig = { + talk: { + provider: "acme", + providers: { + acme: { + apiKey: "runtime-active-talk-key", + voiceId: "active-voice", + clientSecret: "runtime-client-secret", + }, + other: { + apiKey: "runtime-inactive-talk-key", + voiceId: "inactive-voice", + }, + }, + realtime: { + provider: "openai", + providers: { + openai: { + apiKey: "runtime-realtime-key", + authToken: "runtime-realtime-auth-token", + }, + }, + }, + }, + } as OpenClawConfig; + + mocks.getSpeechProvider.mockReturnValue(undefined); + mocks.readConfigFileSnapshot.mockResolvedValue({ + path: "/tmp/openclaw.json", + hash: "test-hash", + valid: true, + config: sourceConfig, + }); + + const respond = vi.fn(); + await talkHandlers["talk.config"]({ + req: { type: "req", id: "1", method: "talk.config" }, + params: { includeSecrets: true }, + client: { connect: { scopes: ["operator.talk.secrets"] } } as never, + isWebchatConnect: () => false, + respond: respond as never, + context: { getRuntimeConfig: () => runtimeConfig } as never, + }); + + const response = expectRespondOk(respond) as { config?: { talk?: Record } }; + const resolved = response.config?.talk?.resolved as Record | undefined; + expectRecordFields(resolved?.config, { + apiKey: "runtime-active-talk-key", + clientSecret: "__OPENCLAW_REDACTED__", + }); + const serialized = JSON.stringify(response); + expect(serialized).toContain("runtime-active-talk-key"); + expect(serialized).not.toContain("source-active-talk-key"); + expect(serialized).not.toContain("source-inactive-talk-key"); + expect(serialized).not.toContain("source-realtime-key"); + expect(serialized).not.toContain("source-client-secret"); + expect(serialized).not.toContain("source-realtime-auth-token"); + expect(serialized).not.toContain("runtime-inactive-talk-key"); + expect(serialized).not.toContain("runtime-realtime-key"); + expect(serialized).not.toContain("runtime-client-secret"); + expect(serialized).not.toContain("runtime-realtime-auth-token"); + }); + + it("redacts runtime-resolved Talk provider SecretRefs without Talk secret scope", async () => { + const sourceConfig = createTalkConfig({ + source: "env", + provider: "default", + id: "ACME_SPEECH_API_KEY", + }); + const runtimeConfig = createTalkConfig("runtime-resolved-talk-key"); + + mocks.getSpeechProvider.mockReturnValue(undefined); + mocks.readConfigFileSnapshot.mockResolvedValue({ + path: "/tmp/openclaw.json", + hash: "test-hash", + valid: true, + config: sourceConfig, + }); + + const respond = vi.fn(); + await talkHandlers["talk.config"]({ + req: { type: "req", id: "1", method: "talk.config" }, + params: {}, + client: { connect: { scopes: ["operator.read"] } } as never, + isWebchatConnect: () => false, + respond: respond as never, + context: { getRuntimeConfig: () => runtimeConfig } as never, + }); + + const response = expectRespondOk(respond) as { config?: { talk?: Record } }; + const resolved = response.config?.talk?.resolved as Record | undefined; + expectRecordFields(resolved, { provider: "acme" }); + const resolvedConfig = expectRecordFields(resolved?.config, {}); + expectRecordFields(resolvedConfig.apiKey, { + source: "__OPENCLAW_REDACTED__", + provider: "__OPENCLAW_REDACTED__", + id: "__OPENCLAW_REDACTED__", + }); + const serialized = JSON.stringify(response); + expect(serialized).not.toContain("runtime-resolved-talk-key"); + expect(serialized).not.toContain("ACME_SPEECH_API_KEY"); + }); }); describe("talk.session unified handlers", () => { diff --git a/src/gateway/server-methods/talk.ts b/src/gateway/server-methods/talk.ts index 5332dedda564..499984a58828 100644 --- a/src/gateway/server-methods/talk.ts +++ b/src/gateway/server-methods/talk.ts @@ -26,7 +26,11 @@ import { normalizeTalkSection, resolveActiveTalkProviderConfig, } from "../../config/talk.js"; -import type { TalkConfigResponse, TalkProviderConfig } from "../../config/types.gateway.js"; +import type { + TalkConfigResponse, + TalkProviderConfig, + TalkRealtimeConfig, +} from "../../config/types.gateway.js"; import type { OpenClawConfig, TtsConfig, TtsProviderConfigMap } from "../../config/types.js"; import { listRealtimeTranscriptionProviders } from "../../realtime-transcription/provider-registry.js"; import { @@ -45,6 +49,7 @@ import { type TtsDirectiveOverrides, } from "../../tts/tts.js"; import { ADMIN_SCOPE, TALK_SECRETS_SCOPE } from "../operator-scopes.js"; +import { resolveConfiguredSecretInputString } from "../resolve-configured-secret-input-string.js"; import { formatForLog } from "../ws-log.js"; import { talkClientHandlers } from "./talk-client.js"; import { talkSessionHandlers } from "./talk-session.js"; @@ -396,24 +401,23 @@ function inferMimeType( return undefined; } -function resolveTalkResponseFromConfig(params: { +async function resolveTalkResponseFromConfig(params: { includeSecrets: boolean; sourceConfig: OpenClawConfig; runtimeConfig: OpenClawConfig; -}): TalkConfigResponse | undefined { +}): Promise { const normalizedTalk = normalizeTalkSection(params.sourceConfig.talk); if (!normalizedTalk) { return undefined; } - const payload = buildTalkConfigResponse(normalizedTalk); - if (!payload) { + const sourcePayload = buildTalkConfigResponse(normalizedTalk); + if (!sourcePayload) { return undefined; } - - if (params.includeSecrets) { - return payload; - } + const payload = params.includeSecrets + ? projectTalkSourcePayloadForSecrets(sourcePayload) + : sourcePayload; const sourceResolved = resolveActiveTalkProviderConfig(normalizedTalk); const runtimeResolved = resolveActiveTalkProviderConfig(params.runtimeConfig.talk); @@ -436,15 +440,18 @@ function resolveTalkResponseFromConfig(params: { Object.keys(runtimeBaseTts).length > 0 ? runtimeBaseTts : stripUnresolvedSecretApiKeysFromBaseTtsProviders(sourceBaseTts); - // Prefer runtime-resolved provider config (already-substituted secrets) and - // fall back to source. Strip any apiKey that is still a SecretRef wrapper — - // provider plugins (ElevenLabs/OpenAI) call strict secret helpers that throw - // on unresolved wrappers, and the discovery path doesn't need the resolved - // value: the response's apiKey is restored from source so the UI keeps the - // SecretRef shape, and redaction strips the value when includeSecrets=false. - const providerInputConfig = stripUnresolvedSecretApiKey( - Object.keys(runtimeProviderConfig).length > 0 ? runtimeProviderConfig : sourceProviderConfig, - ); + // Prefer runtime-resolved provider config and fall back to source. Provider + // plugins (ElevenLabs/OpenAI) call strict secret helpers that throw on + // unresolved wrappers, so only the already-authorized includeSecrets path may + // materialize SecretRef apiKey values before provider resolution. Read-scope + // calls keep the old strip/redact behavior. + const providerInputConfig = await resolveTalkProviderInputConfig({ + includeSecrets: params.includeSecrets, + config: params.runtimeConfig, + providerConfig: + Object.keys(runtimeProviderConfig).length > 0 ? runtimeProviderConfig : sourceProviderConfig, + provider, + }); const resolvedConfig = speechProvider?.resolveTalkConfig?.({ cfg: params.runtimeConfig, @@ -452,10 +459,11 @@ function resolveTalkResponseFromConfig(params: { talkProviderConfig: providerInputConfig, timeoutMs: typeof selectedBaseTts.timeoutMs === "number" ? selectedBaseTts.timeoutMs : 30_000, }) ?? providerInputConfig; - const responseConfig = - sourceProviderConfig.apiKey === undefined - ? resolvedConfig - : { ...resolvedConfig, apiKey: sourceProviderConfig.apiKey }; + const responseConfig = projectTalkResolvedProviderConfig({ + includeSecrets: params.includeSecrets, + sourceProviderConfig, + resolvedConfig, + }); return { ...payload, @@ -467,6 +475,86 @@ function resolveTalkResponseFromConfig(params: { }; } +function projectTalkResolvedProviderConfig(params: { + includeSecrets: boolean; + sourceProviderConfig: TalkProviderConfig; + resolvedConfig: TalkProviderConfig; +}): TalkProviderConfig { + if (!params.includeSecrets) { + return params.sourceProviderConfig.apiKey === undefined + ? params.resolvedConfig + : { ...params.resolvedConfig, apiKey: params.sourceProviderConfig.apiKey }; + } + + // includeSecrets authorizes the active Talk provider key only. Keep resolver + // defaults in the resolved payload, but do not turn arbitrary provider-owned + // secret-like fields into a new native-client credential surface. + const projected = redactConfigObject(params.resolvedConfig); + const apiKey = normalizeOptionalString(params.resolvedConfig.apiKey); + return apiKey === undefined ? projected : { ...projected, apiKey }; +} + +function projectTalkSourceProviderConfigForSecrets(config: TalkProviderConfig): TalkProviderConfig { + const projected = redactConfigObject(config); + if (config.apiKey === undefined || typeof config.apiKey === "string") { + return projected; + } + return { ...projected, apiKey: config.apiKey }; +} + +function projectTalkSourceProviderMapForSecrets( + providers: Record | undefined, +): Record | undefined { + if (!providers) { + return undefined; + } + return Object.fromEntries( + Object.entries(providers).map(([providerId, providerConfig]) => [ + providerId, + projectTalkSourceProviderConfigForSecrets(providerConfig), + ]), + ); +} + +function projectTalkRealtimeForSecrets(realtime: TalkRealtimeConfig): TalkRealtimeConfig { + const projected = redactConfigObject(realtime); + const providers = projectTalkSourceProviderMapForSecrets(realtime.providers); + return providers ? { ...projected, providers } : projected; +} + +function projectTalkSourcePayloadForSecrets(payload: TalkConfigResponse): TalkConfigResponse { + const projected = redactConfigObject(payload); + const providers = projectTalkSourceProviderMapForSecrets(payload.providers); + if (providers) { + projected.providers = providers; + } + if (payload.realtime) { + projected.realtime = projectTalkRealtimeForSecrets(payload.realtime); + } + return projected; +} + +async function resolveTalkProviderInputConfig(params: { + includeSecrets: boolean; + config: OpenClawConfig; + providerConfig: TalkProviderConfig; + provider: string; +}): Promise { + const strippedConfig = stripUnresolvedSecretApiKey(params.providerConfig); + if (!params.includeSecrets || params.providerConfig.apiKey === undefined) { + return strippedConfig; + } + const resolved = await resolveConfiguredSecretInputString({ + config: params.config, + env: process.env, + value: params.providerConfig.apiKey, + path: `talk.providers.${params.provider}.apiKey`, + }); + return resolved.value === undefined + ? strippedConfig + : { ...params.providerConfig, apiKey: resolved.value }; +} + function stripUnresolvedSecretApiKey(config: TalkProviderConfig): TalkProviderConfig { return stripUnresolvedSecretApiKeyFromRecord(config) as TalkProviderConfig; } @@ -564,7 +652,7 @@ export const talkHandlers: GatewayRequestHandlers = { const runtimeConfig = context.getRuntimeConfig(); const configPayload: Record = {}; - const talk = resolveTalkResponseFromConfig({ + const talk = await resolveTalkResponseFromConfig({ includeSecrets, sourceConfig: snapshot.config, runtimeConfig, diff --git a/src/gateway/server.talk-config.test.ts b/src/gateway/server.talk-config.test.ts index 54ad1532179b..8cdcbe9fe827 100644 --- a/src/gateway/server.talk-config.test.ts +++ b/src/gateway/server.talk-config.test.ts @@ -302,7 +302,8 @@ describe("gateway talk.config", () => { expect(res.ok).toBe(true); expectTalkConfig(res.payload?.config?.talk, { provider: GENERIC_TALK_PROVIDER_ID, - apiKey: "secret-key-abc", + providerApiKey: "__OPENCLAW_REDACTED__", + resolvedApiKey: "secret-key-abc", }); }); }); @@ -313,7 +314,10 @@ describe("gateway talk.config", () => { }); await withEnvAsync({ [GENERIC_TALK_API_ENV]: "env-acme-key" }, async () => { - await expectTalkSecretsConfig({ apiKey: talkApiSecretRef() }); + await expectTalkSecretsConfig({ + providerApiKey: talkApiSecretRef(), + resolvedApiKey: "env-acme-key", + }); }); }); @@ -402,7 +406,8 @@ describe("gateway talk.config", () => { await expectTalkSecretsConfig({ voiceId: "voice-secretref", - apiKey: talkApiSecretRef(), + providerApiKey: talkApiSecretRef(), + resolvedApiKey: "env-acme-key", }); }, ); From 2deb696ef498d800e1053d7b8af30b9c574723e2 Mon Sep 17 00:00:00 2001 From: Cameron Beeley Date: Wed, 24 Jun 2026 01:22:31 +0100 Subject: [PATCH 28/29] feat(gateway): scoped attach grants for external MCP loopback clients Per-session, TTL-bounded, revocable bearer grants (mcp-grant-store) let an external/interactive harness reach the gateway's scoped MCP loopback tools without the cli-backend's process-global token. A grant is a lower-trust boundary: it binds the session server-side AND fail-closes on every caller-supplied context header (x-session-key plus message-channel/account/current-channel/thread/ source-reply/event-kind), so a grant holder can neither scope-shop the session nor spoof delivery/action context into scoped tools or the message tool. New attach.grant/attach.revoke operator methods mint/revoke grants and return the loopback MCP config. Owner/non-owner cli-backend path unchanged. --- src/gateway/mcp-grant-store.test.ts | 91 +++++++++++++++ src/gateway/mcp-grant-store.ts | 128 ++++++++++++++++++++++ src/gateway/mcp-http.request.ts | 45 ++++++-- src/gateway/mcp-http.test.ts | 42 +++++++ src/gateway/methods/core-descriptors.ts | 5 + src/gateway/server-methods.ts | 8 ++ src/gateway/server-methods/attach.test.ts | 99 +++++++++++++++++ src/gateway/server-methods/attach.ts | 66 +++++++++++ 8 files changed, 477 insertions(+), 7 deletions(-) create mode 100644 src/gateway/mcp-grant-store.test.ts create mode 100644 src/gateway/mcp-grant-store.ts create mode 100644 src/gateway/server-methods/attach.test.ts create mode 100644 src/gateway/server-methods/attach.ts diff --git a/src/gateway/mcp-grant-store.test.ts b/src/gateway/mcp-grant-store.test.ts new file mode 100644 index 000000000000..740838fc2f95 --- /dev/null +++ b/src/gateway/mcp-grant-store.test.ts @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + resetAttachGrantsForTest, + attachGrantStoreSize, + mintAttachGrant, + resolveAttachGrant, + revokeAttachGrant, + revokeAttachGrantsForSession, + sweepExpiredAttachGrants, +} from "./mcp-grant-store.js"; + +const T0 = 1_000_000_000_000; // fixed epoch for deterministic TTL tests + +describe("mcp-grant-store", () => { + beforeEach(() => resetAttachGrantsForTest()); + + it("mints a grant bound to the sessionKey with a token and a TTL window", () => { + const g = mintAttachGrant({ sessionKey: "agent:main:main", ttlMs: 60_000, nowMs: T0 }); + expect(g.sessionKey).toBe("agent:main:main"); + expect(g.token).toMatch(/^[0-9a-f]{64}$/); + expect(g.issuedAtMs).toBe(T0); + expect(g.expiresAtMs).toBe(T0 + 60_000); + }); + + it("requires a non-empty sessionKey", () => { + expect(() => mintAttachGrant({ sessionKey: " ", nowMs: T0 })).toThrow(); + }); + + it("resolves a live grant and drops it once expired (TTL)", () => { + const g = mintAttachGrant({ sessionKey: "agent:main:x", ttlMs: 1_000, nowMs: T0 }); + expect(resolveAttachGrant(g.token, T0)?.sessionKey).toBe("agent:main:x"); + expect(resolveAttachGrant(g.token, T0 + 999)?.sessionKey).toBe("agent:main:x"); + // at/after expiry → undefined, and the lookup self-sweeps it + expect(resolveAttachGrant(g.token, T0 + 1_000)).toBeUndefined(); + expect(resolveAttachGrant(g.token, T0 + 1_001)).toBeUndefined(); + expect(attachGrantStoreSize()).toBe(0); + }); + + it("returns undefined for an unknown token (no scope without a grant)", () => { + expect(resolveAttachGrant("deadbeef", T0)).toBeUndefined(); + }); + + it("binds the sessionKey to the grant (token carries scope identity, not the caller)", () => { + const a = mintAttachGrant({ sessionKey: "agent:main:telegram:1", nowMs: T0 }); + const b = mintAttachGrant({ sessionKey: "agent:main:telegram:2", nowMs: T0 }); + // each token resolves only to its own bound session — the basis for header-spoof resistance + expect(resolveAttachGrant(a.token, T0)?.sessionKey).toBe("agent:main:telegram:1"); + expect(resolveAttachGrant(b.token, T0)?.sessionKey).toBe("agent:main:telegram:2"); + expect(a.token).not.toBe(b.token); + }); + + it("revokes by token", () => { + const g = mintAttachGrant({ sessionKey: "agent:main:x", nowMs: T0 }); + expect(revokeAttachGrant(g.token)).toBe(true); + expect(resolveAttachGrant(g.token, T0)).toBeUndefined(); + expect(revokeAttachGrant(g.token)).toBe(false); + }); + + it("revokes all grants for a session", () => { + mintAttachGrant({ sessionKey: "agent:main:x", nowMs: T0 }); + mintAttachGrant({ sessionKey: "agent:main:x", nowMs: T0 }); + mintAttachGrant({ sessionKey: "agent:main:y", nowMs: T0 }); + expect(revokeAttachGrantsForSession("agent:main:x")).toBe(2); + expect(attachGrantStoreSize()).toBe(1); + }); + + it("clamps TTL: default for non-positive, ceiling at 12h", () => { + const def = mintAttachGrant({ sessionKey: "s", nowMs: T0 }); + expect(def.expiresAtMs).toBe(T0 + 60 * 60 * 1000); + const zero = mintAttachGrant({ sessionKey: "s", ttlMs: 0, nowMs: T0 }); + expect(zero.expiresAtMs).toBe(T0 + 60 * 60 * 1000); + const huge = mintAttachGrant({ sessionKey: "s", ttlMs: 999 * 60 * 60 * 1000, nowMs: T0 }); + expect(huge.expiresAtMs).toBe(T0 + 12 * 60 * 60 * 1000); + }); + + it("sweeps expired grants", () => { + mintAttachGrant({ sessionKey: "s", ttlMs: 1_000, nowMs: T0 }); + mintAttachGrant({ sessionKey: "s", ttlMs: 5_000, nowMs: T0 }); + expect(sweepExpiredAttachGrants(T0 + 2_000)).toBe(1); + expect(attachGrantStoreSize()).toBe(1); + }); + + it("evicts expired grants on mint, bounding the store (no accumulation)", () => { + mintAttachGrant({ sessionKey: "s", ttlMs: 1_000, nowMs: T0 }); + expect(attachGrantStoreSize()).toBe(1); + // a later mint past the first's expiry sweeps the stale entry before inserting — without + // sweep-on-mint the never-looked-up grant would linger and the size would be 2. + mintAttachGrant({ sessionKey: "s", ttlMs: 1_000, nowMs: T0 + 5_000 }); + expect(attachGrantStoreSize()).toBe(1); + }); +}); diff --git a/src/gateway/mcp-grant-store.ts b/src/gateway/mcp-grant-store.ts new file mode 100644 index 000000000000..c227b702b4e5 --- /dev/null +++ b/src/gateway/mcp-grant-store.ts @@ -0,0 +1,128 @@ +/** + * Per-session MCP loopback **attach grants**. + * + * The loopback MCP server (`mcp-http.ts`) authenticates the gateway-spawned cli-backend with two + * process-global bearer tokens (owner / non-owner) and scopes tools from client-supplied headers — + * adequate because that client is cooperative and gateway-launched. An **attach** grant is the + * primitive for a *less-trusted* external/interactive harness (Claude Code, OpenCode, or a harness + * reached via a node/companion app over its existing gateway connection): a short-lived, revocable + * bearer whose **scope is bound to the grant**, not to the request headers. + * + * Security properties: + * - The bound `sessionKey` comes from the grant, so an attach caller cannot scope-shop by setting + * `x-session-key` (the request layer ignores the header when a grant matches). + * - Grants are always treated as **non-owner** (`senderIsOwner=false`) — the loopback surface + * already excludes the destructive native tools (`read/write/edit/apply_patch/exec/process`). + * - TTL + explicit revoke bound the blast radius of a leaked token. + * + * Transport-independent: the same grant is presented whether the harness reaches the loopback over + * 127.0.0.1 (gateway host) or tunnelled in over a node/app's existing authenticated channel. + */ +import crypto from "node:crypto"; + +export interface McpAttachGrant { + /** Opaque bearer presented as `Authorization: Bearer `. */ + readonly token: string; + /** The openclaw session this grant is bound to; tool scope is resolved for this key. */ + readonly sessionKey: string; + /** Absolute expiry (ms epoch). */ + readonly expiresAtMs: number; + /** Absolute mint time (ms epoch). */ + readonly issuedAtMs: number; +} + +const DEFAULT_TTL_MS = 60 * 60 * 1000; // 1h +const MAX_TTL_MS = 12 * 60 * 60 * 1000; // hard ceiling so a caller can't request a forever-grant + +const grantsByToken = new Map(); + +function clampTtlMs(ttlMs: number | undefined): number { + if (!Number.isFinite(ttlMs) || (ttlMs as number) <= 0) { + return DEFAULT_TTL_MS; + } + return Math.min(ttlMs as number, MAX_TTL_MS); +} + +/** Mint a grant bound to `sessionKey`. Returns the grant (the caller hands `token` to the harness). */ +export function mintAttachGrant(params: { + sessionKey: string; + ttlMs?: number; + nowMs?: number; +}): McpAttachGrant { + const sessionKey = params.sessionKey.trim(); + if (!sessionKey) { + throw new Error("mintAttachGrant: sessionKey is required"); + } + const nowMs = params.nowMs ?? Date.now(); + // Sweep on mint so grants that are minted but never looked up again (harness never connects) + // don't accumulate — lookup self-sweeps only the token it touches, so this bounds the map. + sweepExpiredAttachGrants(nowMs); + const grant: McpAttachGrant = { + token: crypto.randomBytes(32).toString("hex"), + sessionKey, + issuedAtMs: nowMs, + expiresAtMs: nowMs + clampTtlMs(params.ttlMs), + }; + grantsByToken.set(grant.token, grant); + return grant; +} + +/** + * Resolve a bearer token to a live grant, or `undefined` if unknown/expired. An expired grant is + * dropped on lookup (lazy sweep). Lookup is by full-token map key: a caller must already hold the + * complete 256-bit token to get a hit, so there is no partial-match timing oracle to defend. + */ +export function resolveAttachGrant( + token: string, + nowMs: number = Date.now(), +): McpAttachGrant | undefined { + const grant = grantsByToken.get(token); + if (!grant) { + return undefined; + } + if (nowMs >= grant.expiresAtMs) { + grantsByToken.delete(token); + return undefined; + } + return grant; +} + +/** Revoke a grant by token. Returns true if a grant was removed. */ +export function revokeAttachGrant(token: string): boolean { + return grantsByToken.delete(token); +} + +/** Revoke every live grant for a session (e.g. on session teardown). Returns the count removed. */ +export function revokeAttachGrantsForSession(sessionKey: string): number { + const key = sessionKey.trim(); + let removed = 0; + for (const [token, grant] of grantsByToken) { + if (grant.sessionKey === key) { + grantsByToken.delete(token); + removed += 1; + } + } + return removed; +} + +/** Drop expired grants. Returns the count swept. Call opportunistically; lookup also self-sweeps. */ +export function sweepExpiredAttachGrants(nowMs: number = Date.now()): number { + let removed = 0; + for (const [token, grant] of grantsByToken) { + if (nowMs >= grant.expiresAtMs) { + grantsByToken.delete(token); + removed += 1; + } + } + return removed; +} + +/** Number of entries currently held (test/diagnostics). Does not sweep; reflects raw store size. */ +export function attachGrantStoreSize(): number { + return grantsByToken.size; +} + +/** Clear all grants (test isolation only). */ +export function resetAttachGrantsForTest(): void { + grantsByToken.clear(); +} diff --git a/src/gateway/mcp-http.request.ts b/src/gateway/mcp-http.request.ts index 9e32ae7c1451..3443de0c87d7 100644 --- a/src/gateway/mcp-http.request.ts +++ b/src/gateway/mcp-http.request.ts @@ -10,6 +10,7 @@ import { isTruthyEnvValue } from "../infra/env.js"; import { safeEqualSecret } from "../security/secret-equal.js"; import { normalizeMessageChannel } from "../utils/message-channel.js"; import { getHeader } from "./http-utils.js"; +import { resolveAttachGrant } from "./mcp-grant-store.js"; import { isLoopbackAddress } from "./net.js"; import { checkBrowserOrigin } from "./origin-check.js"; @@ -112,14 +113,22 @@ function resolveMcpSender(params: { req: IncomingMessage; ownerToken: string; nonOwnerToken: string; -}): { senderIsOwner: boolean } | undefined { +}): { senderIsOwner: boolean; boundSessionKey?: string } | undefined { const authHeader = getHeader(params.req, "authorization") ?? ""; const ownerTokenMatched = safeEqualSecret(authHeader, `Bearer ${params.ownerToken}`); const nonOwnerTokenMatched = safeEqualSecret(authHeader, `Bearer ${params.nonOwnerToken}`); - if (!ownerTokenMatched && !nonOwnerTokenMatched) { - return undefined; + if (ownerTokenMatched || nonOwnerTokenMatched) { + return { senderIsOwner: ownerTokenMatched }; } - return { senderIsOwner: ownerTokenMatched }; + // Attach grant: an external/interactive harness presents a per-session grant token (mcp-grant-store). + // Always non-owner, and its scope is bound to the grant's sessionKey so a grant holder cannot widen + // scope via the x-session-key header — resolveMcpRequestContext honors boundSessionKey instead. + const grantToken = authHeader.startsWith("Bearer ") ? authHeader.slice("Bearer ".length) : ""; + const grant = grantToken ? resolveAttachGrant(grantToken) : undefined; + if (grant) { + return { senderIsOwner: false, boundSessionKey: grant.sessionKey }; + } + return undefined; } export function validateMcpLoopbackRequest(params: { @@ -128,7 +137,7 @@ export function validateMcpLoopbackRequest(params: { ownerToken: string; nonOwnerToken: string; onSseResponse?: (res: ServerResponse) => void; -}): { senderIsOwner: boolean } | null { +}): { senderIsOwner: boolean; boundSessionKey?: string } | null { let url: URL; try { url = new URL(params.req.url ?? "/", `http://${params.req.headers.host ?? "localhost"}`); @@ -244,7 +253,7 @@ export function validateMcpLoopbackRequest(params: { return null; } - return { senderIsOwner: sender.senderIsOwner }; + return { senderIsOwner: sender.senderIsOwner, boundSessionKey: sender.boundSessionKey }; } export async function readMcpHttpBody( @@ -357,8 +366,30 @@ export function resolveMcpCliCaptureKey(req: IncomingMessage): string | undefine export function resolveMcpRequestContext( req: IncomingMessage, cfg: OpenClawConfig, - auth: { senderIsOwner: boolean }, + auth: { senderIsOwner: boolean; boundSessionKey?: string }, ): McpRequestContext { + // An attach grant is a lower-trust boundary: bind the session server-side AND ignore every + // caller-supplied delivery/action context header (message channel, account, current channel/ + // thread/message, inbound-audio, event-kind, source-reply mode, explicit-target). Those headers + // feed scoped tools and the message tool, so a grant holder must not be able to spoof them — + // only the grant's pinned sessionKey is trusted. Owner/non-owner (the cooperative, gateway- + // launched cli-backend) keep header-driven context. + if (auth.boundSessionKey) { + return { + sessionKey: auth.boundSessionKey, + sessionId: undefined, + messageProvider: undefined, + currentChannelId: undefined, + currentThreadTs: undefined, + currentMessageId: undefined, + currentInboundAudio: undefined, + accountId: undefined, + inboundEventKind: undefined, + sourceReplyDeliveryMode: undefined, + requireExplicitMessageTarget: undefined, + senderIsOwner: auth.senderIsOwner, + }; + } return { sessionKey: resolveScopedSessionKey(cfg, getHeader(req, "x-session-key")), sessionId: normalizeOptionalString(getHeader(req, "x-openclaw-session-id")), diff --git a/src/gateway/mcp-http.test.ts b/src/gateway/mcp-http.test.ts index 09b88691514f..de6fef3cbc83 100644 --- a/src/gateway/mcp-http.test.ts +++ b/src/gateway/mcp-http.test.ts @@ -102,6 +102,7 @@ vi.mock("./tool-resolution.js", () => ({ resolveGatewayScopedToolsMock(...args), })); +import { resetAttachGrantsForTest, mintAttachGrant } from "./mcp-grant-store.js"; import { createMcpLoopbackServerConfig, closeMcpLoopbackServer, @@ -724,6 +725,47 @@ describe("mcp loopback server", () => { ]); }); + it("binds an attach grant's session and ignores ALL spoofed context headers (no scope-shop)", async () => { + resetAttachGrantsForTest(); + const grant = mintAttachGrant({ sessionKey: "agent:main:attach-host" }); + const port = await getFreePortBlockWithPermissionFallback({ + offsets: [0], + fallbackBase: 53_000, + }); + const { port: serverPort } = await startLoopbackServerForTest(port); + + const response = await sendRaw({ + port: serverPort, + token: grant.token, + headers: jsonHeaders({ + // spoof the full delivery/action context, not just the session key + "x-session-key": "agent:main:SPOOFED-other-session", + "x-openclaw-message-channel": "telegram", + "x-openclaw-account-id": "victim-account", + "x-openclaw-current-channel-id": "telegram:victim-chat", + "x-openclaw-current-thread-ts": "999", + "x-openclaw-source-reply-delivery-mode": "automatic", + "x-openclaw-inbound-event-kind": "room_event", + }), + body: mcpToolsListBody(), + }); + + expect(response.status).toBe(200); + const call = getScopedToolsCall(0); + // session is grant-bound, NOT the spoofed header + expect(call.sessionKey).toBe("agent:main:attach-host"); + expect(call.senderIsOwner).toBe(false); + expect(call.surface).toBe("loopback"); + // a grant is a lower-trust boundary: every other caller-supplied context header is ignored + // (fail-closed), so a grant holder cannot spoof delivery/action context into scoped tools. + expect(call.messageProvider).toBeUndefined(); + expect(call.accountId).toBeUndefined(); + expect(call.currentChannelId).toBeUndefined(); + expect(call.currentThreadTs).toBeUndefined(); + expect(call.sourceReplyDeliveryMode).toBeUndefined(); + expect(call.inboundEventKind).toBeUndefined(); + }); + it("routes sessions_yield to the current CLI capture", async () => { resolveGatewayScopedToolsMock.mockImplementation((input): MockGatewayScopedTools => { const call = input as ScopedToolsCall; diff --git a/src/gateway/methods/core-descriptors.ts b/src/gateway/methods/core-descriptors.ts index 10ed1f5a9b1c..f5138fbb55e6 100644 --- a/src/gateway/methods/core-descriptors.ts +++ b/src/gateway/methods/core-descriptors.ts @@ -218,6 +218,11 @@ export const CORE_GATEWAY_METHOD_SPECS: readonly CoreGatewayMethodSpec[] = [ { name: "poll", scope: "operator.write", advertise: false }, { name: "sessions.steer", scope: "operator.write", advertise: false }, { name: "push.test", scope: "operator.write", advertise: false }, + // Appended at the end of the advertised methods to preserve the legacy advertised order + // (server-methods-list.test.ts locks the prefix/middle). grant mints process-global state so it + // is control-plane rate-limited; revoke is intentionally not (cheap, idempotent, frees memory). + { name: "attach.grant", scope: "operator.admin", controlPlaneWrite: true }, + { name: "attach.revoke", scope: "operator.admin" }, { name: "push.web.vapidPublicKey", scope: "operator.write", advertise: false }, { name: "push.web.subscribe", scope: "operator.write", advertise: false }, { name: "push.web.unsubscribe", scope: "operator.write", advertise: false }, diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index 57ae4e38c74a..d0c5b1fbdd5e 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -74,6 +74,10 @@ const loadArtifactsHandlers = lazyHandlerModule( () => import("./server-methods/artifacts.js"), (module) => module.artifactsHandlers, ); +const loadAttachHandlers = lazyHandlerModule( + () => import("./server-methods/attach.js"), + (module) => module.attachHandlers, +); const loadChannelsHandlers = lazyHandlerModule( () => import("./server-methods/channels.js"), (module) => module.channelsHandlers, @@ -271,6 +275,10 @@ export const coreGatewayHandlers: GatewayRequestHandlers = { methods: ["connect"], loadHandlers: loadConnectHandlers, }), + ...createLazyCoreHandlers({ + methods: ["attach.grant", "attach.revoke"], + loadHandlers: loadAttachHandlers, + }), ...createLazyCoreHandlers({ methods: ["logs.tail"], loadHandlers: loadLogsHandlers, diff --git a/src/gateway/server-methods/attach.test.ts b/src/gateway/server-methods/attach.test.ts new file mode 100644 index 000000000000..1ec91f98c302 --- /dev/null +++ b/src/gateway/server-methods/attach.test.ts @@ -0,0 +1,99 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { resetAttachGrantsForTest, resolveAttachGrant } from "../mcp-grant-store.js"; +import { closeMcpLoopbackServer } from "../mcp-http.js"; +import { attachHandlers } from "./attach.js"; +import type { GatewayRequestHandlerOptions } from "./types.js"; + +const grantOpts = (sessionKey: string, respond: ReturnType) => + ({ + params: { sessionKey }, + respond, + context: { getRuntimeConfig: () => ({}) }, + }) as unknown as GatewayRequestHandlerOptions; + +describe("attach gateway methods", () => { + beforeEach(() => resetAttachGrantsForTest()); + afterEach(async () => { + resetAttachGrantsForTest(); + // attach.grant lazily starts the loopback singleton; close it so it doesn't leak across files. + await closeMcpLoopbackServer(); + }); + + it("attach.grant mints a session-bound grant and returns loopback config + token env", async () => { + const respond = vi.fn(); + await attachHandlers["attach.grant"](grantOpts("agent:main:attach-method", respond)); + + expect(respond).toHaveBeenCalledTimes(1); + const [ok, payload] = respond.mock.calls[0]; + expect(ok).toBe(true); + const body = payload as { + token: string; + sessionKey: string; + mcpConfig: unknown; + env: Record; + }; + expect(body.sessionKey).toBe("agent:main:attach-method"); + expect(body.token).toMatch(/^[0-9a-f]{64}$/); + expect(body.mcpConfig).toBeTruthy(); + expect(body.env.OPENCLAW_MCP_TOKEN).toBe(body.token); + expect(body.env.OPENCLAW_MCP_SESSION_KEY).toBe("agent:main:attach-method"); + // the minted token resolves to the bound session in the shared store + expect(resolveAttachGrant(body.token)?.sessionKey).toBe("agent:main:attach-method"); + }); + + it("attach.revoke removes a grant; missing token is an INVALID_REQUEST", async () => { + const grantRespond = vi.fn(); + await attachHandlers["attach.grant"](grantOpts("agent:main:revoke-me", grantRespond)); + const token = (grantRespond.mock.calls[0][1] as { token: string }).token; + + const revokeRespond = vi.fn(); + await attachHandlers["attach.revoke"]({ + params: { token }, + respond: revokeRespond, + } as unknown as GatewayRequestHandlerOptions); + expect(revokeRespond).toHaveBeenCalledWith(true, { revoked: true }); + expect(resolveAttachGrant(token)).toBeUndefined(); + + const errRespond = vi.fn(); + await attachHandlers["attach.revoke"]({ + params: {}, + respond: errRespond, + } as unknown as GatewayRequestHandlerOptions); + const [errOk, , err] = errRespond.mock.calls[0]; + expect(errOk).toBe(false); + expect((err as { code: string }).code).toBe("INVALID_REQUEST"); + }); + + it("applies a positive ttlMs and falls back to the default for an invalid one", async () => { + const r1 = vi.fn(); + await attachHandlers["attach.grant"]({ + params: { sessionKey: "agent:main:ttl", ttlMs: 30_000 }, + respond: r1, + context: { getRuntimeConfig: () => ({}) }, + } as unknown as GatewayRequestHandlerOptions); + const now1 = Date.now(); + const b1 = r1.mock.calls[0][1] as { expiresAtMs: number }; + expect(b1.expiresAtMs).toBeGreaterThan(now1 + 20_000); + expect(b1.expiresAtMs).toBeLessThan(now1 + 40_000); // honored 30s ttl, not the 1h default + + const r2 = vi.fn(); + await attachHandlers["attach.grant"]({ + params: { sessionKey: "agent:main:ttl2", ttlMs: -5 }, // non-positive → default ttl + respond: r2, + context: { getRuntimeConfig: () => ({}) }, + } as unknown as GatewayRequestHandlerOptions); + const b2 = r2.mock.calls[0][1] as { expiresAtMs: number }; + expect(b2.expiresAtMs).toBeGreaterThan(Date.now() + 50 * 60_000); // ~1h default window + }); + + it("attach.revoke treats non-object params as a missing token (INVALID_REQUEST)", async () => { + const respond = vi.fn(); + await attachHandlers["attach.revoke"]({ + params: null, + respond, + } as unknown as GatewayRequestHandlerOptions); + const [ok, , err] = respond.mock.calls[0]; + expect(ok).toBe(false); + expect((err as { code: string }).code).toBe("INVALID_REQUEST"); + }); +}); diff --git a/src/gateway/server-methods/attach.ts b/src/gateway/server-methods/attach.ts new file mode 100644 index 000000000000..943c5927ccd3 --- /dev/null +++ b/src/gateway/server-methods/attach.ts @@ -0,0 +1,66 @@ +// Gateway RPC handlers for attach grants: mint a per-session, scoped, revocable MCP loopback grant +// so an external/interactive harness can reach the gateway's scoped tools, and revoke it on detach. +import { ErrorCodes, errorShape } from "../../../packages/gateway-protocol/src/index.js"; +import { resolveMainSessionKey } from "../../config/sessions.js"; +import { mintAttachGrant, revokeAttachGrant } from "../mcp-grant-store.js"; +import { ensureMcpLoopbackServer } from "../mcp-http.js"; +import { + createMcpLoopbackServerConfig, + getActiveMcpLoopbackRuntime, +} from "../mcp-http.loopback-runtime.js"; +import type { GatewayRequestHandlers } from "./types.js"; + +function paramRecord(params: unknown): Record { + return params && typeof params === "object" ? (params as Record) : {}; +} + +function readString(params: unknown, key: string): string | undefined { + const value = paramRecord(params)[key]; + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function readPositiveNumber(params: unknown, key: string): number | undefined { + const value = paramRecord(params)[key]; + return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined; +} + +export const attachHandlers: GatewayRequestHandlers = { + // Mint a grant bound to a session, returning the loopback MCP config + the token env the harness + // needs. ensureMcpLoopbackServer lazily brings the singleton up if no cli-backend turn started it. + "attach.grant": async ({ params, respond, context }) => { + await ensureMcpLoopbackServer(); + const runtime = getActiveMcpLoopbackRuntime(); + if (!runtime) { + respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, "mcp loopback server unavailable"), + ); + return; + } + const sessionKey = + readString(params, "sessionKey") ?? resolveMainSessionKey(context.getRuntimeConfig()); + const grant = mintAttachGrant({ sessionKey, ttlMs: readPositiveNumber(params, "ttlMs") }); + respond(true, { + sessionKey: grant.sessionKey, + token: grant.token, + expiresAtMs: grant.expiresAtMs, + // The harness writes mcpConfig to its MCP client config and sets env so the ${...} placeholders + // resolve. Loopback today; node/app conduits reuse the same client config over their channel. + mcpConfig: createMcpLoopbackServerConfig(runtime.port), + env: { + OPENCLAW_MCP_TOKEN: grant.token, + OPENCLAW_MCP_SESSION_KEY: grant.sessionKey, + }, + }); + }, + // Revoke a previously minted grant. Idempotent: an unknown/already-expired token reports revoked=false. + "attach.revoke": async ({ params, respond }) => { + const token = readString(params, "token"); + if (!token) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "token is required")); + return; + } + respond(true, { revoked: revokeAttachGrant(token) }); + }, +}; From 1241885db6ad7e6d1f9bc29a6c629e664ac8b305 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Tue, 30 Jun 2026 16:47:21 -0700 Subject: [PATCH 29/29] refactor(gateway): trim attach grant implementation --- src/gateway/mcp-grant-store.test.ts | 6 +--- src/gateway/mcp-grant-store.ts | 36 ++--------------------- src/gateway/mcp-http.request.ts | 11 ++----- src/gateway/mcp-http.test.ts | 4 --- src/gateway/methods/core-descriptors.ts | 3 -- src/gateway/server-methods/attach.test.ts | 5 ++-- src/gateway/server-methods/attach.ts | 22 +++++--------- 7 files changed, 15 insertions(+), 72 deletions(-) diff --git a/src/gateway/mcp-grant-store.test.ts b/src/gateway/mcp-grant-store.test.ts index 740838fc2f95..b6e136a1e663 100644 --- a/src/gateway/mcp-grant-store.test.ts +++ b/src/gateway/mcp-grant-store.test.ts @@ -9,7 +9,7 @@ import { sweepExpiredAttachGrants, } from "./mcp-grant-store.js"; -const T0 = 1_000_000_000_000; // fixed epoch for deterministic TTL tests +const T0 = 1_000_000_000_000; describe("mcp-grant-store", () => { beforeEach(() => resetAttachGrantsForTest()); @@ -30,7 +30,6 @@ describe("mcp-grant-store", () => { const g = mintAttachGrant({ sessionKey: "agent:main:x", ttlMs: 1_000, nowMs: T0 }); expect(resolveAttachGrant(g.token, T0)?.sessionKey).toBe("agent:main:x"); expect(resolveAttachGrant(g.token, T0 + 999)?.sessionKey).toBe("agent:main:x"); - // at/after expiry → undefined, and the lookup self-sweeps it expect(resolveAttachGrant(g.token, T0 + 1_000)).toBeUndefined(); expect(resolveAttachGrant(g.token, T0 + 1_001)).toBeUndefined(); expect(attachGrantStoreSize()).toBe(0); @@ -43,7 +42,6 @@ describe("mcp-grant-store", () => { it("binds the sessionKey to the grant (token carries scope identity, not the caller)", () => { const a = mintAttachGrant({ sessionKey: "agent:main:telegram:1", nowMs: T0 }); const b = mintAttachGrant({ sessionKey: "agent:main:telegram:2", nowMs: T0 }); - // each token resolves only to its own bound session — the basis for header-spoof resistance expect(resolveAttachGrant(a.token, T0)?.sessionKey).toBe("agent:main:telegram:1"); expect(resolveAttachGrant(b.token, T0)?.sessionKey).toBe("agent:main:telegram:2"); expect(a.token).not.toBe(b.token); @@ -83,8 +81,6 @@ describe("mcp-grant-store", () => { it("evicts expired grants on mint, bounding the store (no accumulation)", () => { mintAttachGrant({ sessionKey: "s", ttlMs: 1_000, nowMs: T0 }); expect(attachGrantStoreSize()).toBe(1); - // a later mint past the first's expiry sweeps the stale entry before inserting — without - // sweep-on-mint the never-looked-up grant would linger and the size would be 2. mintAttachGrant({ sessionKey: "s", ttlMs: 1_000, nowMs: T0 + 5_000 }); expect(attachGrantStoreSize()).toBe(1); }); diff --git a/src/gateway/mcp-grant-store.ts b/src/gateway/mcp-grant-store.ts index c227b702b4e5..536af1a50c80 100644 --- a/src/gateway/mcp-grant-store.ts +++ b/src/gateway/mcp-grant-store.ts @@ -1,23 +1,3 @@ -/** - * Per-session MCP loopback **attach grants**. - * - * The loopback MCP server (`mcp-http.ts`) authenticates the gateway-spawned cli-backend with two - * process-global bearer tokens (owner / non-owner) and scopes tools from client-supplied headers — - * adequate because that client is cooperative and gateway-launched. An **attach** grant is the - * primitive for a *less-trusted* external/interactive harness (Claude Code, OpenCode, or a harness - * reached via a node/companion app over its existing gateway connection): a short-lived, revocable - * bearer whose **scope is bound to the grant**, not to the request headers. - * - * Security properties: - * - The bound `sessionKey` comes from the grant, so an attach caller cannot scope-shop by setting - * `x-session-key` (the request layer ignores the header when a grant matches). - * - Grants are always treated as **non-owner** (`senderIsOwner=false`) — the loopback surface - * already excludes the destructive native tools (`read/write/edit/apply_patch/exec/process`). - * - TTL + explicit revoke bound the blast radius of a leaked token. - * - * Transport-independent: the same grant is presented whether the harness reaches the loopback over - * 127.0.0.1 (gateway host) or tunnelled in over a node/app's existing authenticated channel. - */ import crypto from "node:crypto"; export interface McpAttachGrant { @@ -32,7 +12,7 @@ export interface McpAttachGrant { } const DEFAULT_TTL_MS = 60 * 60 * 1000; // 1h -const MAX_TTL_MS = 12 * 60 * 60 * 1000; // hard ceiling so a caller can't request a forever-grant +const MAX_TTL_MS = 12 * 60 * 60 * 1000; const grantsByToken = new Map(); @@ -43,7 +23,6 @@ function clampTtlMs(ttlMs: number | undefined): number { return Math.min(ttlMs as number, MAX_TTL_MS); } -/** Mint a grant bound to `sessionKey`. Returns the grant (the caller hands `token` to the harness). */ export function mintAttachGrant(params: { sessionKey: string; ttlMs?: number; @@ -54,8 +33,7 @@ export function mintAttachGrant(params: { throw new Error("mintAttachGrant: sessionKey is required"); } const nowMs = params.nowMs ?? Date.now(); - // Sweep on mint so grants that are minted but never looked up again (harness never connects) - // don't accumulate — lookup self-sweeps only the token it touches, so this bounds the map. + // Mint sweeps stale entries so abandoned grants do not accumulate. sweepExpiredAttachGrants(nowMs); const grant: McpAttachGrant = { token: crypto.randomBytes(32).toString("hex"), @@ -67,11 +45,6 @@ export function mintAttachGrant(params: { return grant; } -/** - * Resolve a bearer token to a live grant, or `undefined` if unknown/expired. An expired grant is - * dropped on lookup (lazy sweep). Lookup is by full-token map key: a caller must already hold the - * complete 256-bit token to get a hit, so there is no partial-match timing oracle to defend. - */ export function resolveAttachGrant( token: string, nowMs: number = Date.now(), @@ -87,12 +60,10 @@ export function resolveAttachGrant( return grant; } -/** Revoke a grant by token. Returns true if a grant was removed. */ export function revokeAttachGrant(token: string): boolean { return grantsByToken.delete(token); } -/** Revoke every live grant for a session (e.g. on session teardown). Returns the count removed. */ export function revokeAttachGrantsForSession(sessionKey: string): number { const key = sessionKey.trim(); let removed = 0; @@ -105,7 +76,6 @@ export function revokeAttachGrantsForSession(sessionKey: string): number { return removed; } -/** Drop expired grants. Returns the count swept. Call opportunistically; lookup also self-sweeps. */ export function sweepExpiredAttachGrants(nowMs: number = Date.now()): number { let removed = 0; for (const [token, grant] of grantsByToken) { @@ -117,12 +87,10 @@ export function sweepExpiredAttachGrants(nowMs: number = Date.now()): number { return removed; } -/** Number of entries currently held (test/diagnostics). Does not sweep; reflects raw store size. */ export function attachGrantStoreSize(): number { return grantsByToken.size; } -/** Clear all grants (test isolation only). */ export function resetAttachGrantsForTest(): void { grantsByToken.clear(); } diff --git a/src/gateway/mcp-http.request.ts b/src/gateway/mcp-http.request.ts index 3443de0c87d7..96f0c44a7388 100644 --- a/src/gateway/mcp-http.request.ts +++ b/src/gateway/mcp-http.request.ts @@ -120,9 +120,6 @@ function resolveMcpSender(params: { if (ownerTokenMatched || nonOwnerTokenMatched) { return { senderIsOwner: ownerTokenMatched }; } - // Attach grant: an external/interactive harness presents a per-session grant token (mcp-grant-store). - // Always non-owner, and its scope is bound to the grant's sessionKey so a grant holder cannot widen - // scope via the x-session-key header — resolveMcpRequestContext honors boundSessionKey instead. const grantToken = authHeader.startsWith("Bearer ") ? authHeader.slice("Bearer ".length) : ""; const grant = grantToken ? resolveAttachGrant(grantToken) : undefined; if (grant) { @@ -368,12 +365,8 @@ export function resolveMcpRequestContext( cfg: OpenClawConfig, auth: { senderIsOwner: boolean; boundSessionKey?: string }, ): McpRequestContext { - // An attach grant is a lower-trust boundary: bind the session server-side AND ignore every - // caller-supplied delivery/action context header (message channel, account, current channel/ - // thread/message, inbound-audio, event-kind, source-reply mode, explicit-target). Those headers - // feed scoped tools and the message tool, so a grant holder must not be able to spoof them — - // only the grant's pinned sessionKey is trusted. Owner/non-owner (the cooperative, gateway- - // launched cli-backend) keep header-driven context. + // Grant-authenticated callers get only their server-bound session; spoofable + // delivery/action headers stay reserved for the gateway-launched loopback client. if (auth.boundSessionKey) { return { sessionKey: auth.boundSessionKey, diff --git a/src/gateway/mcp-http.test.ts b/src/gateway/mcp-http.test.ts index de6fef3cbc83..21f0ae8169bd 100644 --- a/src/gateway/mcp-http.test.ts +++ b/src/gateway/mcp-http.test.ts @@ -738,7 +738,6 @@ describe("mcp loopback server", () => { port: serverPort, token: grant.token, headers: jsonHeaders({ - // spoof the full delivery/action context, not just the session key "x-session-key": "agent:main:SPOOFED-other-session", "x-openclaw-message-channel": "telegram", "x-openclaw-account-id": "victim-account", @@ -752,12 +751,9 @@ describe("mcp loopback server", () => { expect(response.status).toBe(200); const call = getScopedToolsCall(0); - // session is grant-bound, NOT the spoofed header expect(call.sessionKey).toBe("agent:main:attach-host"); expect(call.senderIsOwner).toBe(false); expect(call.surface).toBe("loopback"); - // a grant is a lower-trust boundary: every other caller-supplied context header is ignored - // (fail-closed), so a grant holder cannot spoof delivery/action context into scoped tools. expect(call.messageProvider).toBeUndefined(); expect(call.accountId).toBeUndefined(); expect(call.currentChannelId).toBeUndefined(); diff --git a/src/gateway/methods/core-descriptors.ts b/src/gateway/methods/core-descriptors.ts index f5138fbb55e6..a01262a3c00f 100644 --- a/src/gateway/methods/core-descriptors.ts +++ b/src/gateway/methods/core-descriptors.ts @@ -218,9 +218,6 @@ export const CORE_GATEWAY_METHOD_SPECS: readonly CoreGatewayMethodSpec[] = [ { name: "poll", scope: "operator.write", advertise: false }, { name: "sessions.steer", scope: "operator.write", advertise: false }, { name: "push.test", scope: "operator.write", advertise: false }, - // Appended at the end of the advertised methods to preserve the legacy advertised order - // (server-methods-list.test.ts locks the prefix/middle). grant mints process-global state so it - // is control-plane rate-limited; revoke is intentionally not (cheap, idempotent, frees memory). { name: "attach.grant", scope: "operator.admin", controlPlaneWrite: true }, { name: "attach.revoke", scope: "operator.admin" }, { name: "push.web.vapidPublicKey", scope: "operator.write", advertise: false }, diff --git a/src/gateway/server-methods/attach.test.ts b/src/gateway/server-methods/attach.test.ts index 1ec91f98c302..ecd77cf14522 100644 --- a/src/gateway/server-methods/attach.test.ts +++ b/src/gateway/server-methods/attach.test.ts @@ -37,7 +37,6 @@ describe("attach gateway methods", () => { expect(body.mcpConfig).toBeTruthy(); expect(body.env.OPENCLAW_MCP_TOKEN).toBe(body.token); expect(body.env.OPENCLAW_MCP_SESSION_KEY).toBe("agent:main:attach-method"); - // the minted token resolves to the bound session in the shared store expect(resolveAttachGrant(body.token)?.sessionKey).toBe("agent:main:attach-method"); }); @@ -78,12 +77,12 @@ describe("attach gateway methods", () => { const r2 = vi.fn(); await attachHandlers["attach.grant"]({ - params: { sessionKey: "agent:main:ttl2", ttlMs: -5 }, // non-positive → default ttl + params: { sessionKey: "agent:main:ttl2", ttlMs: -5 }, respond: r2, context: { getRuntimeConfig: () => ({}) }, } as unknown as GatewayRequestHandlerOptions); const b2 = r2.mock.calls[0][1] as { expiresAtMs: number }; - expect(b2.expiresAtMs).toBeGreaterThan(Date.now() + 50 * 60_000); // ~1h default window + expect(b2.expiresAtMs).toBeGreaterThan(Date.now() + 50 * 60_000); }); it("attach.revoke treats non-object params as a missing token (INVALID_REQUEST)", async () => { diff --git a/src/gateway/server-methods/attach.ts b/src/gateway/server-methods/attach.ts index 943c5927ccd3..83e64cfcde98 100644 --- a/src/gateway/server-methods/attach.ts +++ b/src/gateway/server-methods/attach.ts @@ -1,5 +1,3 @@ -// Gateway RPC handlers for attach grants: mint a per-session, scoped, revocable MCP loopback grant -// so an external/interactive harness can reach the gateway's scoped tools, and revoke it on detach. import { ErrorCodes, errorShape } from "../../../packages/gateway-protocol/src/index.js"; import { resolveMainSessionKey } from "../../config/sessions.js"; import { mintAttachGrant, revokeAttachGrant } from "../mcp-grant-store.js"; @@ -14,20 +12,19 @@ function paramRecord(params: unknown): Record { return params && typeof params === "object" ? (params as Record) : {}; } -function readString(params: unknown, key: string): string | undefined { - const value = paramRecord(params)[key]; +function readString(params: Record, key: string): string | undefined { + const value = params[key]; return typeof value === "string" && value.trim() ? value.trim() : undefined; } -function readPositiveNumber(params: unknown, key: string): number | undefined { - const value = paramRecord(params)[key]; +function readPositiveNumber(params: Record, key: string): number | undefined { + const value = params[key]; return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined; } export const attachHandlers: GatewayRequestHandlers = { - // Mint a grant bound to a session, returning the loopback MCP config + the token env the harness - // needs. ensureMcpLoopbackServer lazily brings the singleton up if no cli-backend turn started it. "attach.grant": async ({ params, respond, context }) => { + const grantParams = paramRecord(params); await ensureMcpLoopbackServer(); const runtime = getActiveMcpLoopbackRuntime(); if (!runtime) { @@ -39,14 +36,12 @@ export const attachHandlers: GatewayRequestHandlers = { return; } const sessionKey = - readString(params, "sessionKey") ?? resolveMainSessionKey(context.getRuntimeConfig()); - const grant = mintAttachGrant({ sessionKey, ttlMs: readPositiveNumber(params, "ttlMs") }); + readString(grantParams, "sessionKey") ?? resolveMainSessionKey(context.getRuntimeConfig()); + const grant = mintAttachGrant({ sessionKey, ttlMs: readPositiveNumber(grantParams, "ttlMs") }); respond(true, { sessionKey: grant.sessionKey, token: grant.token, expiresAtMs: grant.expiresAtMs, - // The harness writes mcpConfig to its MCP client config and sets env so the ${...} placeholders - // resolve. Loopback today; node/app conduits reuse the same client config over their channel. mcpConfig: createMcpLoopbackServerConfig(runtime.port), env: { OPENCLAW_MCP_TOKEN: grant.token, @@ -54,9 +49,8 @@ export const attachHandlers: GatewayRequestHandlers = { }, }); }, - // Revoke a previously minted grant. Idempotent: an unknown/already-expired token reports revoked=false. "attach.revoke": async ({ params, respond }) => { - const token = readString(params, "token"); + const token = readString(paramRecord(params), "token"); if (!token) { respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "token is required")); return;