From 427b3e0b49e42385ff97c865217d24d48b683b66 Mon Sep 17 00:00:00 2001 From: moonrailgun Date: Fri, 7 Aug 2026 11:00:25 +0800 Subject: [PATCH 01/13] feat(agent): persist usage statistics --- .../agent/dao_agent_settings_handler.cc | 458 ++++++++++++++++++ .../agent/dao_agent_settings_handler.h | 72 +++ .../dao_agent_settings_handler_unittest.cc | 194 ++++++++ src/dao/browser/dao_pref_names.cc | 48 +- src/dao/browser/dao_pref_names.h | 39 +- 5 files changed, 770 insertions(+), 41 deletions(-) create mode 100644 src/dao/browser/agent/dao_agent_settings_handler.cc create mode 100644 src/dao/browser/agent/dao_agent_settings_handler.h create mode 100644 src/dao/browser/agent/dao_agent_settings_handler_unittest.cc diff --git a/src/dao/browser/agent/dao_agent_settings_handler.cc b/src/dao/browser/agent/dao_agent_settings_handler.cc new file mode 100644 index 00000000..ec9001ef --- /dev/null +++ b/src/dao/browser/agent/dao_agent_settings_handler.cc @@ -0,0 +1,458 @@ +// Copyright 2026 Dao Browser Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "dao/browser/agent/dao_agent_settings_handler.h" + +#include +#include +#include +#include +#include + +#include "base/functional/bind.h" +#include "base/json/json_reader.h" +#include "base/json/json_writer.h" +#include "base/strings/string_util.h" +#include "chrome/browser/profiles/profile.h" +#include "components/prefs/pref_service.h" +#include "components/prefs/scoped_user_pref_update.h" +#include "content/public/browser/web_ui.h" +#include "dao/browser/agent/dao_dream_domain_utils.h" +#include "dao/browser/dao_pref_names.h" + +namespace dao { +namespace { + +constexpr char kSettingsChangedEvent[] = "dao-agent-settings-changed"; +constexpr size_t kMaxSettingValueBytes = 256 * 1024; +constexpr size_t kMaxSettingsBytes = 512 * 1024; + +constexpr char kUsageStatsApiCalls[] = "apiCalls"; +constexpr char kUsageStatsToolCalls[] = "toolCalls"; +constexpr char kUsageStatsPromptTokens[] = "promptTokens"; +constexpr char kUsageStatsCompletionTokens[] = "completionTokens"; +constexpr char kUsageStatsTotalTokens[] = "totalTokens"; +constexpr char kUsageStatsEstimatedCost[] = "estimatedCost"; +constexpr char kUsageStatsLastReset[] = "lastReset"; + +constexpr std::array kManagedStringSettings = { + "dao_agent_providers", "dao_agent_active_provider", + "dao_agent_api_key", "dao_agent_base_url", + "dao_agent_model", "dao_agent_soul", + "dao_disabled_tools", "dao_tool_call_show_details", + "dao_agent_debug_mode", "dao_resume_last_session", + "dao_resume_stale_hours", "dao_proactive_enabled", + "dao_page_context_enabled", "dao_conversation_enabled", + "dao_proactive_threshold", "dao_search_source", + "dao_jina_api_key", +}; + +bool ParseBooleanSetting(const base::Value &value, bool *parsed) { + if (!value.is_string()) { + return false; + } + if (value.GetString() == "true") { + *parsed = true; + return true; + } + if (value.GetString() == "false") { + *parsed = false; + return true; + } + return false; +} + +std::string SerializeDreamExcludedDomains(PrefService *prefs) { + std::string json; + base::JSONWriter::Write(prefs->GetList(prefs::kDaoDreamExcludedDomains), + &json); + return json; +} + +bool SetDreamExcludedDomains(PrefService *prefs, const base::Value &value) { + if (!value.is_string() || value.GetString().size() > kMaxSettingValueBytes) { + return false; + } + std::optional parsed = + base::JSONReader::Read(value.GetString(), base::JSON_PARSE_RFC); + if (!parsed || !parsed->is_list()) { + return false; + } + std::set normalized; + for (const base::Value &entry : parsed->GetList()) { + if (!entry.is_string()) { + return false; + } + std::string domain = NormalizeDreamExcludedDomain(entry.GetString()); + if (domain.empty()) { + return false; + } + normalized.insert(std::move(domain)); + } + base::ListValue domains; + for (const std::string &domain : normalized) { + domains.Append(domain); + } + prefs->SetList(prefs::kDaoDreamExcludedDomains, std::move(domains)); + return true; +} + +std::optional ReadNonNegativeFiniteNumber(const base::Value *value) { + if (!value) { + return std::nullopt; + } + const double number = value->is_int() + ? static_cast(value->GetInt()) + : (value->is_double() ? value->GetDouble() : -1.0); + return number >= 0.0 && std::isfinite(number) ? std::optional(number) + : std::nullopt; +} + +base::DictValue NewUsageStats(base::Time last_reset) { + base::DictValue stats; + stats.Set(kUsageStatsApiCalls, 0.0); + stats.Set(kUsageStatsToolCalls, base::DictValue()); + stats.Set(kUsageStatsPromptTokens, 0.0); + stats.Set(kUsageStatsCompletionTokens, 0.0); + stats.Set(kUsageStatsTotalTokens, 0.0); + stats.Set(kUsageStatsEstimatedCost, 0.0); + stats.Set(kUsageStatsLastReset, + static_cast(last_reset.InMillisecondsSinceUnixEpoch())); + return stats; +} + +bool IsValidUsageToolName(std::string_view tool_name) { + return !tool_name.empty() && + tool_name.size() <= kMaxDaoAgentUsageToolNameBytes && + base::IsStringUTF8(tool_name); +} + +bool NormalizeUsageStats(const base::DictValue &source, + base::DictValue *normalized) { + const std::optional api_calls = + ReadNonNegativeFiniteNumber(source.Find(kUsageStatsApiCalls)); + const std::optional prompt_tokens = + ReadNonNegativeFiniteNumber(source.Find(kUsageStatsPromptTokens)); + const std::optional completion_tokens = + ReadNonNegativeFiniteNumber(source.Find(kUsageStatsCompletionTokens)); + const std::optional total_tokens = + ReadNonNegativeFiniteNumber(source.Find(kUsageStatsTotalTokens)); + const std::optional estimated_cost = + ReadNonNegativeFiniteNumber(source.Find(kUsageStatsEstimatedCost)); + const std::optional last_reset = + ReadNonNegativeFiniteNumber(source.Find(kUsageStatsLastReset)); + const base::DictValue *tool_calls = source.FindDict(kUsageStatsToolCalls); + if (!api_calls || !prompt_tokens || !completion_tokens || !total_tokens || + !estimated_cost || !last_reset || !tool_calls || + *total_tokens != *prompt_tokens + *completion_tokens || + tool_calls->size() > kMaxDaoAgentUsageTools) { + return false; + } + + base::DictValue normalized_tools; + for (const auto [tool_name, count] : *tool_calls) { + const std::optional tool_count = + ReadNonNegativeFiniteNumber(&count); + if (!IsValidUsageToolName(tool_name) || !tool_count) { + return false; + } + normalized_tools.Set(tool_name, *tool_count); + } + + normalized->Set(kUsageStatsApiCalls, *api_calls); + normalized->Set(kUsageStatsToolCalls, std::move(normalized_tools)); + normalized->Set(kUsageStatsPromptTokens, *prompt_tokens); + normalized->Set(kUsageStatsCompletionTokens, *completion_tokens); + normalized->Set(kUsageStatsTotalTokens, *total_tokens); + normalized->Set(kUsageStatsEstimatedCost, *estimated_cost); + normalized->Set(kUsageStatsLastReset, *last_reset); + return true; +} + +void StoreUsageStats(ScopedDictPrefUpdate *update, base::DictValue stats) { + update->clear(); + update->Merge(std::move(stats)); +} + +base::DictValue ReadUsageStatsOrDefault(PrefService *prefs) { + base::DictValue normalized; + if (NormalizeUsageStats(prefs->GetDict(prefs::kDaoAgentUsageStats), + &normalized)) { + return normalized; + } + return NewUsageStats(base::Time::Now()); +} + +} // namespace + +bool IsManagedDaoAgentSetting(std::string_view key) { + for (std::string_view candidate : kManagedStringSettings) { + if (candidate == key) { + return true; + } + } + return key == kDaoAgentMemoryEnabledSetting || + key == kDaoDreamEnabledSetting || key == kDaoDreamDebugSetting || + key == kDaoDreamExcludedDomainsSetting; +} + +base::DictValue BuildDaoAgentSettingsSnapshot(PrefService *prefs) { + base::DictValue snapshot; + snapshot.Set("migrationVersion", + prefs->GetInteger(prefs::kDaoAgentSettingsMigrationVersion)); + + base::DictValue values = prefs->GetDict(prefs::kDaoAgentSettings).Clone(); + values.Set(kDaoAgentMemoryEnabledSetting, + prefs->GetBoolean(prefs::kDaoAgentMemoryEnabled) ? "true" + : "false"); + values.Set(kDaoDreamEnabledSetting, + prefs->GetBoolean(prefs::kDaoDreamEnabled) ? "true" : "false"); + values.Set(kDaoDreamDebugSetting, + prefs->GetBoolean(prefs::kDaoDreamDebug) ? "true" : "false"); + values.Set(kDaoDreamExcludedDomainsSetting, + SerializeDreamExcludedDomains(prefs)); + snapshot.Set("values", std::move(values)); + snapshot.Set("usageStats", BuildDaoAgentUsageStats(prefs)); + return snapshot; +} + +base::DictValue BuildDaoAgentUsageStats(PrefService *prefs) { + return prefs ? ReadUsageStatsOrDefault(prefs) : base::DictValue(); +} + +bool MigrateLegacyDaoAgentUsageStats(PrefService *prefs, + const base::Value *legacy_value) { + if (!prefs || !legacy_value || !legacy_value->is_string() || + !prefs->GetDict(prefs::kDaoAgentUsageStats).empty()) { + return false; + } + std::optional parsed = + base::JSONReader::Read(legacy_value->GetString(), base::JSON_PARSE_RFC); + if (!parsed || !parsed->is_dict()) { + return false; + } + base::DictValue normalized; + if (!NormalizeUsageStats(parsed->GetDict(), &normalized)) { + return false; + } + ScopedDictPrefUpdate update(prefs, prefs::kDaoAgentUsageStats); + StoreUsageStats(&update, std::move(normalized)); + return true; +} + +void RecordDaoAgentApiUsage(PrefService *prefs, double api_calls, + double prompt_tokens, double completion_tokens, + double estimated_cost) { + if (!prefs || !std::isfinite(api_calls) || !std::isfinite(prompt_tokens) || + !std::isfinite(completion_tokens) || !std::isfinite(estimated_cost) || + api_calls < 0.0 || prompt_tokens < 0.0 || completion_tokens < 0.0 || + estimated_cost < 0.0) { + return; + } + + base::DictValue stats = ReadUsageStatsOrDefault(prefs); + const double next_api_calls = + *stats.FindDouble(kUsageStatsApiCalls) + api_calls; + const double next_prompt_tokens = + *stats.FindDouble(kUsageStatsPromptTokens) + prompt_tokens; + const double next_completion_tokens = + *stats.FindDouble(kUsageStatsCompletionTokens) + completion_tokens; + const double next_total_tokens = next_prompt_tokens + next_completion_tokens; + const double next_estimated_cost = + *stats.FindDouble(kUsageStatsEstimatedCost) + estimated_cost; + if (!std::isfinite(next_api_calls) || !std::isfinite(next_prompt_tokens) || + !std::isfinite(next_completion_tokens) || + !std::isfinite(next_total_tokens) || + !std::isfinite(next_estimated_cost)) { + return; + } + stats.Set(kUsageStatsApiCalls, next_api_calls); + stats.Set(kUsageStatsPromptTokens, next_prompt_tokens); + stats.Set(kUsageStatsCompletionTokens, next_completion_tokens); + stats.Set(kUsageStatsTotalTokens, next_total_tokens); + stats.Set(kUsageStatsEstimatedCost, next_estimated_cost); + ScopedDictPrefUpdate update(prefs, prefs::kDaoAgentUsageStats); + StoreUsageStats(&update, std::move(stats)); +} + +void RecordDaoAgentToolUsage(PrefService *prefs, std::string_view tool_name) { + if (!prefs || !IsValidUsageToolName(tool_name)) { + return; + } + base::DictValue stats = ReadUsageStatsOrDefault(prefs); + base::DictValue *tool_calls = stats.FindDict(kUsageStatsToolCalls); + const base::Value *existing_value = tool_calls->Find(tool_name); + if (!existing_value && tool_calls->size() >= kMaxDaoAgentUsageTools) { + return; + } + const double existing_count = + existing_value ? *existing_value->GetIfDouble() : 0.0; + if (!std::isfinite(existing_count + 1.0)) { + return; + } + tool_calls->Set(tool_name, existing_count + 1.0); + ScopedDictPrefUpdate update(prefs, prefs::kDaoAgentUsageStats); + StoreUsageStats(&update, std::move(stats)); +} + +void ResetDaoAgentUsageStats(PrefService *prefs, base::Time last_reset) { + if (!prefs || last_reset.is_null() || + last_reset.InMillisecondsSinceUnixEpoch() < 0) { + return; + } + ScopedDictPrefUpdate update(prefs, prefs::kDaoAgentUsageStats); + StoreUsageStats(&update, NewUsageStats(last_reset)); +} + +bool SetDaoAgentSetting(PrefService *prefs, std::string_view key, + const base::Value &value) { + if (!prefs || !IsManagedDaoAgentSetting(key)) { + return false; + } + if (key == kDaoAgentMemoryEnabledSetting || key == kDaoDreamEnabledSetting || + key == kDaoDreamDebugSetting) { + bool enabled = false; + if (!ParseBooleanSetting(value, &enabled)) { + return false; + } + const char *pref_name = + key == kDaoAgentMemoryEnabledSetting + ? prefs::kDaoAgentMemoryEnabled + : (key == kDaoDreamEnabledSetting ? prefs::kDaoDreamEnabled + : prefs::kDaoDreamDebug); + prefs->SetBoolean(pref_name, enabled); + return true; + } + if (key == kDaoDreamExcludedDomainsSetting) { + return SetDreamExcludedDomains(prefs, value); + } + + ScopedDictPrefUpdate update(prefs, prefs::kDaoAgentSettings); + if (value.is_none()) { + update->Remove(key); + return true; + } + if (!value.is_string() || value.GetString().size() > kMaxSettingValueBytes) { + return false; + } + update->Set(key, value.GetString()); + return true; +} + +base::DictValue +MigrateLegacyDaoAgentSettings(PrefService *prefs, + const base::DictValue &legacy_values) { + if (!prefs || prefs->GetInteger(prefs::kDaoAgentSettingsMigrationVersion) >= + kDaoAgentSettingsMigrationVersion) { + return prefs ? BuildDaoAgentSettingsSnapshot(prefs) : base::DictValue(); + } + + if (prefs->GetInteger(prefs::kDaoAgentSettingsMigrationVersion) < 1) { + size_t total_bytes = 0; + { + ScopedDictPrefUpdate update(prefs, prefs::kDaoAgentSettings); + for (const auto [key, value] : legacy_values) { + if (!IsManagedDaoAgentSetting(key) || !value.is_string() || + key == kDaoAgentMemoryEnabledSetting || + key == kDaoDreamEnabledSetting || key == kDaoDreamDebugSetting || + key == kDaoDreamExcludedDomainsSetting || update->contains(key)) { + continue; + } + total_bytes += value.GetString().size(); + if (value.GetString().size() > kMaxSettingValueBytes || + total_bytes > kMaxSettingsBytes) { + continue; + } + update->Set(key, value.GetString()); + } + } + } + MigrateLegacyDaoAgentUsageStats(prefs, legacy_values.Find("dao_agent_stats")); + prefs->SetInteger(prefs::kDaoAgentSettingsMigrationVersion, + kDaoAgentSettingsMigrationVersion); + return BuildDaoAgentSettingsSnapshot(prefs); +} + +DaoAgentSettingsHandler::DaoAgentSettingsHandler() = default; +DaoAgentSettingsHandler::~DaoAgentSettingsHandler() = default; + +void DaoAgentSettingsHandler::RegisterMessages() { + web_ui()->RegisterMessageCallback( + "getDaoAgentSettings", + base::BindRepeating(&DaoAgentSettingsHandler::HandleGetSettings, + base::Unretained(this))); + web_ui()->RegisterMessageCallback( + "migrateDaoAgentSettings", + base::BindRepeating(&DaoAgentSettingsHandler::HandleMigrateLegacySettings, + base::Unretained(this))); + web_ui()->RegisterMessageCallback( + "setDaoAgentSetting", + base::BindRepeating(&DaoAgentSettingsHandler::HandleSetSetting, + base::Unretained(this))); +} + +void DaoAgentSettingsHandler::OnJavascriptAllowed() { + PrefService *prefs = GetPrefs(); + if (!prefs || pref_change_registrar_.prefs()) { + return; + } + pref_change_registrar_.Init(prefs); + const auto callback = base::BindRepeating( + &DaoAgentSettingsHandler::OnSettingsChanged, base::Unretained(this)); + pref_change_registrar_.Add(prefs::kDaoAgentSettings, callback); + pref_change_registrar_.Add(prefs::kDaoAgentSettingsMigrationVersion, + callback); + pref_change_registrar_.Add(prefs::kDaoAgentUsageStats, callback); + pref_change_registrar_.Add(prefs::kDaoAgentMemoryEnabled, callback); + pref_change_registrar_.Add(prefs::kDaoDreamEnabled, callback); + pref_change_registrar_.Add(prefs::kDaoDreamDebug, callback); + pref_change_registrar_.Add(prefs::kDaoDreamExcludedDomains, callback); +} + +void DaoAgentSettingsHandler::OnJavascriptDisallowed() { + pref_change_registrar_.Reset(); +} + +void DaoAgentSettingsHandler::HandleGetSettings(const base::ListValue &args) { + AllowJavascript(); + if (args.size() != 1 || !args[0].is_string() || !GetPrefs()) { + return; + } + ResolveJavascriptCallback(args[0], BuildDaoAgentSettingsSnapshot(GetPrefs())); +} + +void DaoAgentSettingsHandler::HandleMigrateLegacySettings( + const base::ListValue &args) { + AllowJavascript(); + if (args.size() != 2 || !args[0].is_string() || !args[1].is_dict() || + !GetPrefs()) { + return; + } + ResolveJavascriptCallback( + args[0], MigrateLegacyDaoAgentSettings(GetPrefs(), args[1].GetDict())); +} + +void DaoAgentSettingsHandler::HandleSetSetting(const base::ListValue &args) { + AllowJavascript(); + if (args.size() != 3 || !args[0].is_string() || !args[1].is_string() || + !GetPrefs()) { + return; + } + ResolveJavascriptCallback( + args[0], SetDaoAgentSetting(GetPrefs(), args[1].GetString(), args[2])); +} + +void DaoAgentSettingsHandler::OnSettingsChanged() { + if (IsJavascriptAllowed() && GetPrefs()) { + FireWebUIListener(kSettingsChangedEvent, + BuildDaoAgentSettingsSnapshot(GetPrefs())); + } +} + +PrefService *DaoAgentSettingsHandler::GetPrefs() { + Profile *profile = Profile::FromWebUI(web_ui()); + return profile ? profile->GetOriginalProfile()->GetPrefs() : nullptr; +} + +} // namespace dao diff --git a/src/dao/browser/agent/dao_agent_settings_handler.h b/src/dao/browser/agent/dao_agent_settings_handler.h new file mode 100644 index 00000000..687b9f14 --- /dev/null +++ b/src/dao/browser/agent/dao_agent_settings_handler.h @@ -0,0 +1,72 @@ +// Copyright 2026 Dao Browser Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef DAO_BROWSER_AGENT_DAO_AGENT_SETTINGS_HANDLER_H_ +#define DAO_BROWSER_AGENT_DAO_AGENT_SETTINGS_HANDLER_H_ + +#include +#include + +#include "base/time/time.h" +#include "base/values.h" +#include "components/prefs/pref_change_registrar.h" +#include "content/public/browser/web_ui_message_handler.h" + +class PrefService; + +namespace dao { + +inline constexpr int kDaoAgentSettingsMigrationVersion = 2; +inline constexpr size_t kMaxDaoAgentUsageTools = 128; +inline constexpr size_t kMaxDaoAgentUsageToolNameBytes = 128; +inline constexpr char kDaoAgentMemoryEnabledSetting[] = + "dao_agent_memory_enabled"; +inline constexpr char kDaoDreamEnabledSetting[] = "dao_dream_enabled"; +inline constexpr char kDaoDreamDebugSetting[] = "dao_dream_debug"; +inline constexpr char kDaoDreamExcludedDomainsSetting[] = + "dao_dream_excluded_domains"; + +bool IsManagedDaoAgentSetting(std::string_view key); +base::DictValue BuildDaoAgentSettingsSnapshot(PrefService *prefs); +base::DictValue BuildDaoAgentUsageStats(PrefService *prefs); +bool MigrateLegacyDaoAgentUsageStats(PrefService *prefs, + const base::Value *legacy_value); +void RecordDaoAgentApiUsage(PrefService *prefs, double api_calls, + double prompt_tokens, double completion_tokens, + double estimated_cost); +void RecordDaoAgentToolUsage(PrefService *prefs, std::string_view tool_name); +void ResetDaoAgentUsageStats(PrefService *prefs, base::Time last_reset); +bool SetDaoAgentSetting(PrefService *prefs, std::string_view key, + const base::Value &value); +base::DictValue +MigrateLegacyDaoAgentSettings(PrefService *prefs, + const base::DictValue &legacy_values); + +// Shared by dao://agent and dao://settings so both origins observe one +// Profile-scoped source of truth. +class DaoAgentSettingsHandler : public content::WebUIMessageHandler { +public: + DaoAgentSettingsHandler(); + DaoAgentSettingsHandler(const DaoAgentSettingsHandler &) = delete; + DaoAgentSettingsHandler &operator=(const DaoAgentSettingsHandler &) = delete; + ~DaoAgentSettingsHandler() override; + + // content::WebUIMessageHandler: + void RegisterMessages() override; + void OnJavascriptAllowed() override; + void OnJavascriptDisallowed() override; + +private: + void HandleGetSettings(const base::ListValue &args); + void HandleMigrateLegacySettings(const base::ListValue &args); + void HandleSetSetting(const base::ListValue &args); + void OnSettingsChanged(); + PrefService *GetPrefs(); + + PrefChangeRegistrar pref_change_registrar_; +}; + +} // namespace dao + +#endif // DAO_BROWSER_AGENT_DAO_AGENT_SETTINGS_HANDLER_H_ diff --git a/src/dao/browser/agent/dao_agent_settings_handler_unittest.cc b/src/dao/browser/agent/dao_agent_settings_handler_unittest.cc new file mode 100644 index 00000000..77a75caf --- /dev/null +++ b/src/dao/browser/agent/dao_agent_settings_handler_unittest.cc @@ -0,0 +1,194 @@ +// Copyright 2026 Dao Browser Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "dao/browser/agent/dao_agent_settings_handler.h" + +#include +#include + +#include "base/time/time.h" +#include "base/values.h" +#include "components/prefs/scoped_user_pref_update.h" +#include "components/prefs/testing_pref_service.h" +#include "dao/browser/dao_pref_names.h" +#include "testing/gtest/include/gtest/gtest.h" + +namespace dao { +namespace { + +class DaoAgentSettingsHandlerTest : public testing::Test { +protected: + void SetUp() override { + prefs_.registry()->RegisterDictionaryPref(prefs::kDaoAgentSettings); + prefs_.registry()->RegisterIntegerPref( + prefs::kDaoAgentSettingsMigrationVersion, 0); + prefs_.registry()->RegisterDictionaryPref(prefs::kDaoAgentUsageStats); + prefs_.registry()->RegisterBooleanPref(prefs::kDaoAgentMemoryEnabled, + false); + prefs_.registry()->RegisterBooleanPref(prefs::kDaoDreamEnabled, false); + prefs_.registry()->RegisterBooleanPref(prefs::kDaoDreamDebug, false); + prefs_.registry()->RegisterListPref(prefs::kDaoDreamExcludedDomains); + } + + TestingPrefServiceSimple prefs_; +}; + +TEST_F(DaoAgentSettingsHandlerTest, UsageStatsDefaultToZero) { + const base::DictValue stats = BuildDaoAgentUsageStats(&prefs_); + EXPECT_EQ(0.0, stats.FindDouble("apiCalls").value_or(-1)); + EXPECT_EQ(0.0, stats.FindDouble("totalTokens").value_or(-1)); + EXPECT_TRUE(stats.FindDict("toolCalls")); +} + +TEST_F(DaoAgentSettingsHandlerTest, RejectsInvalidLegacyUsageStats) { + base::Value invalid("{\"apiCalls\":-1,\"toolCalls\":{}}"); + EXPECT_FALSE(MigrateLegacyDaoAgentUsageStats(&prefs_, &invalid)); + EXPECT_EQ( + 0.0, + BuildDaoAgentUsageStats(&prefs_).FindDouble("apiCalls").value_or(-1)); +} + +TEST_F(DaoAgentSettingsHandlerTest, MigratesValidLegacyUsageStats) { + base::Value legacy( + R"({"apiCalls":2,"toolCalls":{"web_search":3},"promptTokens":4,"completionTokens":5,"totalTokens":9,"estimatedCost":0.15,"lastReset":1234})"); + + EXPECT_TRUE(MigrateLegacyDaoAgentUsageStats(&prefs_, &legacy)); + + const base::DictValue stats = BuildDaoAgentUsageStats(&prefs_); + EXPECT_EQ(2.0, stats.FindDouble("apiCalls").value_or(-1)); + ASSERT_TRUE(stats.FindDict("toolCalls")); + EXPECT_EQ(3.0, + stats.FindDict("toolCalls")->FindDouble("web_search").value_or(-1)); + EXPECT_EQ(4.0, stats.FindDouble("promptTokens").value_or(-1)); + EXPECT_EQ(5.0, stats.FindDouble("completionTokens").value_or(-1)); + EXPECT_EQ(9.0, stats.FindDouble("totalTokens").value_or(-1)); + EXPECT_EQ(0.15, stats.FindDouble("estimatedCost").value_or(-1)); + EXPECT_EQ(1234.0, stats.FindDouble("lastReset").value_or(-1)); +} + +TEST_F(DaoAgentSettingsHandlerTest, ProfileUsageStatsWinAfterMigration) { + base::Value first( + R"({"apiCalls":9,"toolCalls":{"web_search":2},"promptTokens":10,"completionTokens":5,"totalTokens":15,"estimatedCost":0.25,"lastReset":1000})"); + ASSERT_TRUE(MigrateLegacyDaoAgentUsageStats(&prefs_, &first)); + base::Value later( + R"({"apiCalls":99,"toolCalls":{},"promptTokens":0,"completionTokens":0,"totalTokens":0,"estimatedCost":0,"lastReset":2000})"); + EXPECT_FALSE(MigrateLegacyDaoAgentUsageStats(&prefs_, &later)); + EXPECT_EQ( + 9.0, + BuildDaoAgentUsageStats(&prefs_).FindDouble("apiCalls").value_or(-1)); +} + +TEST_F(DaoAgentSettingsHandlerTest, MigratesUsageStatsIntoSettingsSnapshot) { + base::DictValue legacy; + legacy.Set( + "dao_agent_stats", + R"({"apiCalls":1,"toolCalls":{},"promptTokens":2,"completionTokens":3,"totalTokens":5,"estimatedCost":0.1,"lastReset":1000})"); + + const base::DictValue snapshot = + MigrateLegacyDaoAgentSettings(&prefs_, legacy); + + const base::DictValue *usage_stats = snapshot.FindDict("usageStats"); + ASSERT_TRUE(usage_stats); + EXPECT_EQ(1.0, usage_stats->FindDouble("apiCalls").value_or(-1)); + EXPECT_FALSE(snapshot.FindDict("values")->contains("usageStats")); + EXPECT_EQ(2, snapshot.FindInt("migrationVersion").value_or(-1)); +} + +TEST_F(DaoAgentSettingsHandlerTest, RejectsInvalidUsageDeltas) { + RecordDaoAgentApiUsage(&prefs_, 1.0, 2.0, 3.0, 0.5); + RecordDaoAgentApiUsage(&prefs_, -1.0, 0.0, 0.0, 0.0); + RecordDaoAgentApiUsage(&prefs_, 0.0, std::numeric_limits::infinity(), + 0.0, 0.0); + + const base::DictValue stats = BuildDaoAgentUsageStats(&prefs_); + EXPECT_EQ(1.0, stats.FindDouble("apiCalls").value_or(-1)); + EXPECT_EQ(2.0, stats.FindDouble("promptTokens").value_or(-1)); + EXPECT_EQ(3.0, stats.FindDouble("completionTokens").value_or(-1)); + EXPECT_EQ(5.0, stats.FindDouble("totalTokens").value_or(-1)); + EXPECT_EQ(0.5, stats.FindDouble("estimatedCost").value_or(-1)); +} + +TEST_F(DaoAgentSettingsHandlerTest, RejectsToolNamesOverUsageBound) { + RecordDaoAgentToolUsage(&prefs_, + std::string(kMaxDaoAgentUsageToolNameBytes + 1, 'a')); + RecordDaoAgentToolUsage(&prefs_, ""); + + const base::DictValue stats = BuildDaoAgentUsageStats(&prefs_); + EXPECT_TRUE(stats.FindDict("toolCalls")->empty()); +} + +TEST_F(DaoAgentSettingsHandlerTest, AccumulatesAndResetsUsageStats) { + RecordDaoAgentApiUsage(&prefs_, 1.0, 10.0, 4.0, 0.5); + RecordDaoAgentApiUsage(&prefs_, 1.0, 2.0, 3.0, 0.25); + RecordDaoAgentToolUsage(&prefs_, "web_search"); + RecordDaoAgentToolUsage(&prefs_, "web_search"); + + base::DictValue stats = BuildDaoAgentUsageStats(&prefs_); + EXPECT_EQ(2.0, stats.FindDouble("apiCalls").value_or(-1)); + EXPECT_EQ(12.0, stats.FindDouble("promptTokens").value_or(-1)); + EXPECT_EQ(7.0, stats.FindDouble("completionTokens").value_or(-1)); + EXPECT_EQ(19.0, stats.FindDouble("totalTokens").value_or(-1)); + EXPECT_EQ(0.75, stats.FindDouble("estimatedCost").value_or(-1)); + ASSERT_TRUE(stats.FindDict("toolCalls")); + EXPECT_EQ(2.0, + stats.FindDict("toolCalls")->FindDouble("web_search").value_or(-1)); + + ResetDaoAgentUsageStats(&prefs_, + base::Time::FromMillisecondsSinceUnixEpoch(987654)); + stats = BuildDaoAgentUsageStats(&prefs_); + EXPECT_EQ(0.0, stats.FindDouble("apiCalls").value_or(-1)); + EXPECT_TRUE(stats.FindDict("toolCalls")->empty()); + EXPECT_EQ(987654.0, stats.FindDouble("lastReset").value_or(-1)); +} + +TEST_F(DaoAgentSettingsHandlerTest, MigratesKnownMissingValuesOnlyOnce) { + { + ScopedDictPrefUpdate update(&prefs_, prefs::kDaoAgentSettings); + update->Set("dao_agent_model", "settings-model"); + } + base::DictValue legacy; + legacy.Set("dao_agent_model", "legacy-model"); + legacy.Set("dao_agent_api_key", "legacy-key"); + legacy.Set("runtime_only_value", "ignored"); + + base::DictValue snapshot = MigrateLegacyDaoAgentSettings(&prefs_, legacy); + + EXPECT_EQ(kDaoAgentSettingsMigrationVersion, + snapshot.FindInt("migrationVersion").value_or(-1)); + const base::DictValue *values = snapshot.FindDict("values"); + ASSERT_TRUE(values); + EXPECT_EQ("settings-model", *values->FindString("dao_agent_model")); + EXPECT_EQ("legacy-key", *values->FindString("dao_agent_api_key")); + EXPECT_FALSE(values->contains("runtime_only_value")); + + base::DictValue later_legacy; + later_legacy.Set("dao_agent_api_key", "later-key"); + snapshot = MigrateLegacyDaoAgentSettings(&prefs_, later_legacy); + EXPECT_EQ("legacy-key", + *snapshot.FindDict("values")->FindString("dao_agent_api_key")); +} + +TEST_F(DaoAgentSettingsHandlerTest, ValidatesNativeBooleanSettings) { + EXPECT_TRUE(SetDaoAgentSetting(&prefs_, kDaoAgentMemoryEnabledSetting, + base::Value("true"))); + EXPECT_TRUE(prefs_.GetBoolean(prefs::kDaoAgentMemoryEnabled)); + EXPECT_FALSE(SetDaoAgentSetting(&prefs_, kDaoAgentMemoryEnabledSetting, + base::Value("yes"))); + EXPECT_TRUE(prefs_.GetBoolean(prefs::kDaoAgentMemoryEnabled)); +} + +TEST_F(DaoAgentSettingsHandlerTest, NormalizesDreamExcludedDomains) { + EXPECT_TRUE(SetDaoAgentSetting( + &prefs_, kDaoDreamExcludedDomainsSetting, + base::Value(R"(["HTTPS://Example.com/path","sub.example.com"] )"))); + + const base::ListValue &domains = + prefs_.GetList(prefs::kDaoDreamExcludedDomains); + ASSERT_EQ(2u, domains.size()); + EXPECT_EQ("example.com", domains[0].GetString()); + EXPECT_EQ("sub.example.com", domains[1].GetString()); +} + +} // namespace +} // namespace dao diff --git a/src/dao/browser/dao_pref_names.cc b/src/dao/browser/dao_pref_names.cc index 91059577..f81fe0a1 100644 --- a/src/dao/browser/dao_pref_names.cc +++ b/src/dao/browser/dao_pref_names.cc @@ -19,12 +19,15 @@ namespace dao::prefs { -void RegisterLocalStatePrefs(PrefRegistrySimple* registry) { +void RegisterLocalStatePrefs(PrefRegistrySimple *registry) { registry->RegisterBooleanPref(kDaoMcpServerEnabled, false); } -void RegisterProfilePrefs(user_prefs::PrefRegistrySyncable* registry) { +void RegisterProfilePrefs(user_prefs::PrefRegistrySyncable *registry) { registry->RegisterBooleanPref(kDaoAgentMemoryEnabled, false); + registry->RegisterDictionaryPref(kDaoAgentSettings); + registry->RegisterIntegerPref(kDaoAgentSettingsMigrationVersion, 0); + registry->RegisterDictionaryPref(kDaoAgentUsageStats); registry->RegisterDictionaryPref(kDaoSplitLayout); registry->RegisterDictionaryPref(kDaoPipWindowBoundsByOrigin); registry->RegisterBooleanPref(kDaoEnhancedPipEnabled, true); @@ -42,44 +45,42 @@ void RegisterProfilePrefs(user_prefs::PrefRegistrySyncable* registry) { registry->RegisterListPref(kDaoDreamExcludedDomains); } -} // namespace dao::prefs +} // namespace dao::prefs namespace dao { namespace { -Profile* GetStorageProfile(Profile* profile) { +Profile *GetStorageProfile(Profile *profile) { return profile ? profile->GetOriginalProfile() : nullptr; } -bool UsesSameStorageProfile(Profile* lhs, Profile* rhs) { +bool UsesSameStorageProfile(Profile *lhs, Profile *rhs) { return GetStorageProfile(lhs) == GetStorageProfile(rhs); } -} // namespace +} // namespace bool IsSystemDarkMode() { - ui::NativeTheme* theme = ui::NativeTheme::GetInstanceForNativeUi(); + ui::NativeTheme *theme = ui::NativeTheme::GetInstanceForNativeUi(); return theme && theme->preferred_color_scheme() == ui::NativeTheme::PreferredColorScheme::kDark; } -bool IsForceDarkModeUserEnabled(Profile* profile) { - Profile* storage_profile = GetStorageProfile(profile); +bool IsForceDarkModeUserEnabled(Profile *profile) { + Profile *storage_profile = GetStorageProfile(profile); return storage_profile && storage_profile->GetPrefs()->GetBoolean( prefs::kDaoForceDarkModeEnabled); } -bool IsForceDarkModeAvailable() { - return IsSystemDarkMode(); -} +bool IsForceDarkModeAvailable() { return IsSystemDarkMode(); } -bool IsForceDarkModeEffective(Profile* profile) { +bool IsForceDarkModeEffective(Profile *profile) { return IsForceDarkModeAvailable() && IsForceDarkModeUserEnabled(profile); } -void SetForceDarkModeUserEnabled(Profile* profile, bool enabled) { - Profile* storage_profile = GetStorageProfile(profile); +void SetForceDarkModeUserEnabled(Profile *profile, bool enabled) { + Profile *storage_profile = GetStorageProfile(profile); if (!storage_profile) { return; } @@ -89,8 +90,7 @@ void SetForceDarkModeUserEnabled(Profile* profile, bool enabled) { } void ApplyForceDarkModePreferences( - Profile* profile, - blink::web_pref::WebPreferences* web_preferences) { + Profile *profile, blink::web_pref::WebPreferences *web_preferences) { if (!web_preferences) { return; } @@ -107,28 +107,28 @@ void ApplyForceDarkModePreferences( blink::mojom::PreferredColorScheme::kDark; } -void NotifyForceDarkModeChanged(Profile* profile) { - Profile* storage_profile = GetStorageProfile(profile); +void NotifyForceDarkModeChanged(Profile *profile) { + Profile *storage_profile = GetStorageProfile(profile); if (!storage_profile) { return; } - for (BrowserWindowInterface* browser_window : + for (BrowserWindowInterface *browser_window : GetAllBrowserWindowInterfaces()) { - Browser* browser = + Browser *browser = browser_window ? browser_window->GetBrowserForMigrationOnly() : nullptr; if (!browser || !UsesSameStorageProfile(browser->profile(), storage_profile)) { continue; } - TabStripModel* tab_strip_model = browser->tab_strip_model(); + TabStripModel *tab_strip_model = browser->tab_strip_model(); if (!tab_strip_model) { continue; } for (int i = 0; i < tab_strip_model->count(); ++i) { - content::WebContents* contents = tab_strip_model->GetWebContentsAt(i); + content::WebContents *contents = tab_strip_model->GetWebContentsAt(i); if (contents) { contents->OnWebPreferencesChanged(); } @@ -136,4 +136,4 @@ void NotifyForceDarkModeChanged(Profile* profile) { } } -} // namespace dao +} // namespace dao diff --git a/src/dao/browser/dao_pref_names.h b/src/dao/browser/dao_pref_names.h index 62c48ceb..da384a53 100644 --- a/src/dao/browser/dao_pref_names.h +++ b/src/dao/browser/dao_pref_names.h @@ -18,8 +18,8 @@ class PrefRegistrySyncable; namespace dao::prefs { -void RegisterLocalStatePrefs(PrefRegistrySimple* registry); -void RegisterProfilePrefs(user_prefs::PrefRegistrySyncable* registry); +void RegisterLocalStatePrefs(PrefRegistrySimple *registry); +void RegisterProfilePrefs(user_prefs::PrefRegistrySyncable *registry); // Global local-state pref controlling the owner-only local MCP endpoint. // The service is disabled by default and remains independent of Dao Agent. @@ -30,6 +30,15 @@ inline constexpr char kDaoMcpServerEnabled[] = "dao.mcp_server_enabled"; // engine does not run, and the agent sidebar WebUI is not loaded. inline constexpr char kDaoAgentMemoryEnabled[] = "dao.agent_memory_enabled"; +// Profile-scoped canonical store for Dao Agent configuration previously held +// by the dao://agent origin's localStorage. Values intentionally remain +// strings so the resident WebUI can keep a compatibility cache while +// dao://settings becomes the sole editing surface. +inline constexpr char kDaoAgentSettings[] = "dao.agent_settings"; +inline constexpr char kDaoAgentSettingsMigrationVersion[] = + "dao.agent_settings_migration_version"; +inline constexpr char kDaoAgentUsageStats[] = "dao.agent_usage_stats"; + // Dictionary pref storing the split view tree layout per window. // Keyed by window session ID, each entry holds a serialized tree. inline constexpr char kDaoSplitLayout[] = "dao.split_layout"; @@ -76,8 +85,7 @@ inline constexpr char kDaoStaleTabExpirationHours[] = // Dictionary pref storing the most recent Little Dao outer window bounds. // The key keeps its historical "window_size" name for compatibility with // profiles that only stored width and height. -inline constexpr char kDaoLittleDaoWindowSize[] = - "dao.little_dao_window_size"; +inline constexpr char kDaoLittleDaoWindowSize[] = "dao.little_dao_window_size"; // Boolean pref that controls the Dream Analysis system. When false (the // default), the dream scheduler never fires and no browsing data is sent @@ -86,8 +94,7 @@ inline constexpr char kDaoDreamEnabled[] = "dao.dream_enabled"; // Boolean pref that controls the additional weekly Dream report. It is // independently opt-in and requires both Agent memory and Dream Analysis. -inline constexpr char kDaoDreamWeeklyEnabled[] = - "dao.dream_weekly_enabled"; +inline constexpr char kDaoDreamWeeklyEnabled[] = "dao.dream_weekly_enabled"; // Boolean pref for dream debug mode. When true, each dream run persists // the full material pack JSON (the exact LLM input) into the report row @@ -96,24 +103,22 @@ inline constexpr char kDaoDreamDebug[] = "dao.dream_debug"; // List pref storing normalized domains excluded from Dream Analysis material // collection. A value excludes the domain and its subdomains. -inline constexpr char kDaoDreamExcludedDomains[] = - "dao.dream_excluded_domains"; +inline constexpr char kDaoDreamExcludedDomains[] = "dao.dream_excluded_domains"; -} // namespace dao::prefs +} // namespace dao::prefs namespace dao { bool IsSystemDarkMode(); -bool IsForceDarkModeUserEnabled(Profile* profile); +bool IsForceDarkModeUserEnabled(Profile *profile); bool IsForceDarkModeAvailable(); -bool IsForceDarkModeEffective(Profile* profile); +bool IsForceDarkModeEffective(Profile *profile); -void SetForceDarkModeUserEnabled(Profile* profile, bool enabled); +void SetForceDarkModeUserEnabled(Profile *profile, bool enabled); void ApplyForceDarkModePreferences( - Profile* profile, - blink::web_pref::WebPreferences* web_preferences); -void NotifyForceDarkModeChanged(Profile* profile); + Profile *profile, blink::web_pref::WebPreferences *web_preferences); +void NotifyForceDarkModeChanged(Profile *profile); -} // namespace dao +} // namespace dao -#endif // DAO_BROWSER_DAO_PREF_NAMES_H_ +#endif // DAO_BROWSER_DAO_PREF_NAMES_H_ From 49a3380bdb5b7a0eda67843aa22a3986d1d33e88 Mon Sep 17 00:00:00 2001 From: moonrailgun Date: Fri, 7 Aug 2026 11:10:31 +0800 Subject: [PATCH 02/13] fix(agent): make usage updates atomic --- .../settings_redesign_contract.test.ts | 159 ++++++++++++++++++ .../agent/dao_agent_settings_handler.cc | 101 +++++------ .../agent/dao_agent_settings_handler.h | 38 ++--- .../dao_agent_settings_handler_unittest.cc | 25 ++- src/dao/browser/dao_pref_names.cc | 40 ++--- src/dao/browser/dao_pref_names.h | 20 +-- 6 files changed, 281 insertions(+), 102 deletions(-) create mode 100644 scripts/commands/__tests__/settings_redesign_contract.test.ts diff --git a/scripts/commands/__tests__/settings_redesign_contract.test.ts b/scripts/commands/__tests__/settings_redesign_contract.test.ts new file mode 100644 index 00000000..1412c868 --- /dev/null +++ b/scripts/commands/__tests__/settings_redesign_contract.test.ts @@ -0,0 +1,159 @@ +import {existsSync, readFileSync} from 'node:fs'; +import path from 'node:path'; + +import {describe, expect, it} from 'vitest'; + +const patchRoot = + 'src/patches/chrome/browser/resources/settings'; + +function patchPath(relativePath: string): string { + return path.join(process.cwd(), patchRoot, relativePath); +} + +function readPatch(relativePath: string): string { + return readFileSync(patchPath(relativePath), 'utf-8'); +} + +function readDaoSource(relativePath: string): string { + return readFileSync(path.join(process.cwd(), relativePath), 'utf-8'); +} + +function deletionLines(patch: string): string[] { + return patch.split('\n').filter( + line => line.startsWith('-') && !line.startsWith('---')); +} + +function preservedContractToken(line: string): string|null { + return line.match(/href="[^"]+"/)?.[0] ?? + line.match(/routes_\.[A-Z0-9_]+/)?.[0] ?? + line.match(/pageVisibility_\.[A-Za-z0-9_]+/)?.[0] ?? + line.match(/prefs="?[{[]?{?prefs/)?.[0] ?? + line.match(/ { + it('provides an integrated table of contents and overview controller', () => { + const requiredPatches = [ + 'settings_ui/settings_ui.html.patch', + 'settings_ui/settings_ui.ts.patch', + 'settings_menu/settings_menu.html.patch', + 'settings_menu/settings_menu.ts.patch', + 'settings_main/settings_main.ts.patch', + 'settings_page/settings_section.html.patch', + 'settings_shared.css.patch', + ]; + + for (const relativePath of requiredPatches) { + expect(existsSync(patchPath(relativePath)), relativePath).toBe(true); + } + + const uiPatch = readPatch('settings_ui/settings_ui.html.patch'); + const menuPatch = readPatch('settings_menu/settings_menu.html.patch'); + const menuTsPatch = readPatch('settings_menu/settings_menu.ts.patch'); + const mainTsPatch = readPatch('settings_main/settings_main.ts.patch'); + const uiTsPatch = readPatch('settings_ui/settings_ui.ts.patch'); + const sharedCssPatch = readPatch('settings_shared.css.patch'); + expect(uiPatch).toContain('--settings-menu-width: 184px'); + expect(uiPatch).toContain('id="daoSettingsSearch"'); + expect(uiPatch).not.toContain('id="daoSettingsBrand"'); + expect(uiPatch).not.toContain('id="daoSettingsRailFooter"'); + expect(menuPatch).toContain('data-section="dao"'); + expect(menuPatch).not.toContain('dao-settings-menu-group'); + expect(menuTsPatch).toContain('settings-section-activate'); + expect(mainTsPatch).toContain('overviewMode_'); + expect(mainTsPatch).toContain('switchViews('); + expect(uiTsPatch).toContain( + 'const requestedSection = window.location.hash.substring(1)'); + expect(uiTsPatch).toContain( + 'this.syncOverviewRoute_(route, requestedSection)'); + expect(sharedCssPatch).toContain( + '--dao-settings-content-width: 680px'); + }); + + it('does not delete route, setting, preference, or visibility markup', () => { + const behavioralDeletionPatterns = [ + / existsSync(patchPath(relativePath))); + + for (const relativePath of patches) { + const patch = readPatch(relativePath); + const deleted = deletionLines(patch); + const patterns = relativePath.includes('settings_main/') || + relativePath.includes('dao_page/') ? + [...behavioralDeletionPatterns, / line.startsWith('+') && + !line.startsWith('+++')) + .join('\n'); + for (const line of deleted) { + for (const pattern of patterns) { + if (pattern.test(line)) { + const token = preservedContractToken(line); + expect(token, `${relativePath}: ${line}`).not.toBeNull(); + expect(additions, `${relativePath}: ${line}`).toContain(token!); + } + } + } + } + }); + + it('preserves fine-grained Agent tool permissions', () => { + const htmlPatch = readPatch('dao_page/dao_page.html.patch'); + const tsPatch = readPatch('dao_page/dao_page.ts.patch'); + + expect(htmlPatch).toContain('data-tool-name$="[[tool]]"'); + expect(tsPatch).toContain('onAgentToolChange_'); + expect(tsPatch).toContain('Object.values(AGENT_TOOL_GROUPS).flat()'); + }); + + it('keeps nested top-level page views in overview document flow', () => { + const nestedPagePatches = [ + 'people_page/people_page_index.html.patch', + 'autofill_page/autofill_page_index.html.patch', + 'your_saved_info_page/your_saved_info_page_index.html.patch', + 'appearance_page/appearance_page_index.html.patch', + 'a11y_page/a11y_page_index.html.patch', + ]; + + for (const relativePath of nestedPagePatches) { + expect(existsSync(patchPath(relativePath)), relativePath).toBe(true); + expect(readPatch(relativePath), relativePath) + .toContain('[slot=view]:not(.closing)'); + expect(readPatch(relativePath), relativePath) + .toContain('position: initial'); + } + }); + + it('keeps usage-statistic mutations inside their scoped pref updates', () => { + const handler = readDaoSource( + 'src/dao/browser/agent/dao_agent_settings_handler.cc'); + const api_usage = handler.match( + /void RecordDaoAgentApiUsage[\s\S]*?\n}\n\nvoid RecordDaoAgentToolUsage/)?.[0]; + const tool_usage = handler.match( + /void RecordDaoAgentToolUsage[\s\S]*?\n}\n\nvoid ResetDaoAgentUsageStats/)?.[0]; + + expect(api_usage).toBeDefined(); + expect(tool_usage).toBeDefined(); + expect(api_usage).not.toContain('ReadUsageStatsOrDefault'); + expect(tool_usage).not.toContain('ReadUsageStatsOrDefault'); + expect(api_usage).toMatch( + /ScopedDictPrefUpdate update[\s\S]*?update->FindDouble/); + expect(tool_usage).toMatch( + /ScopedDictPrefUpdate update[\s\S]*?update->FindDict/); + }); +}); diff --git a/src/dao/browser/agent/dao_agent_settings_handler.cc b/src/dao/browser/agent/dao_agent_settings_handler.cc index ec9001ef..2ac158a1 100644 --- a/src/dao/browser/agent/dao_agent_settings_handler.cc +++ b/src/dao/browser/agent/dao_agent_settings_handler.cc @@ -48,7 +48,7 @@ constexpr std::array kManagedStringSettings = { "dao_jina_api_key", }; -bool ParseBooleanSetting(const base::Value &value, bool *parsed) { +bool ParseBooleanSetting(const base::Value& value, bool* parsed) { if (!value.is_string()) { return false; } @@ -63,14 +63,14 @@ bool ParseBooleanSetting(const base::Value &value, bool *parsed) { return false; } -std::string SerializeDreamExcludedDomains(PrefService *prefs) { +std::string SerializeDreamExcludedDomains(PrefService* prefs) { std::string json; base::JSONWriter::Write(prefs->GetList(prefs::kDaoDreamExcludedDomains), &json); return json; } -bool SetDreamExcludedDomains(PrefService *prefs, const base::Value &value) { +bool SetDreamExcludedDomains(PrefService* prefs, const base::Value& value) { if (!value.is_string() || value.GetString().size() > kMaxSettingValueBytes) { return false; } @@ -80,7 +80,7 @@ bool SetDreamExcludedDomains(PrefService *prefs, const base::Value &value) { return false; } std::set normalized; - for (const base::Value &entry : parsed->GetList()) { + for (const base::Value& entry : parsed->GetList()) { if (!entry.is_string()) { return false; } @@ -91,14 +91,14 @@ bool SetDreamExcludedDomains(PrefService *prefs, const base::Value &value) { normalized.insert(std::move(domain)); } base::ListValue domains; - for (const std::string &domain : normalized) { + for (const std::string& domain : normalized) { domains.Append(domain); } prefs->SetList(prefs::kDaoDreamExcludedDomains, std::move(domains)); return true; } -std::optional ReadNonNegativeFiniteNumber(const base::Value *value) { +std::optional ReadNonNegativeFiniteNumber(const base::Value* value) { if (!value) { return std::nullopt; } @@ -128,8 +128,8 @@ bool IsValidUsageToolName(std::string_view tool_name) { base::IsStringUTF8(tool_name); } -bool NormalizeUsageStats(const base::DictValue &source, - base::DictValue *normalized) { +bool NormalizeUsageStats(const base::DictValue& source, + base::DictValue* normalized) { const std::optional api_calls = ReadNonNegativeFiniteNumber(source.Find(kUsageStatsApiCalls)); const std::optional prompt_tokens = @@ -142,7 +142,7 @@ bool NormalizeUsageStats(const base::DictValue &source, ReadNonNegativeFiniteNumber(source.Find(kUsageStatsEstimatedCost)); const std::optional last_reset = ReadNonNegativeFiniteNumber(source.Find(kUsageStatsLastReset)); - const base::DictValue *tool_calls = source.FindDict(kUsageStatsToolCalls); + const base::DictValue* tool_calls = source.FindDict(kUsageStatsToolCalls); if (!api_calls || !prompt_tokens || !completion_tokens || !total_tokens || !estimated_cost || !last_reset || !tool_calls || *total_tokens != *prompt_tokens + *completion_tokens || @@ -170,12 +170,19 @@ bool NormalizeUsageStats(const base::DictValue &source, return true; } -void StoreUsageStats(ScopedDictPrefUpdate *update, base::DictValue stats) { +void StoreUsageStats(ScopedDictPrefUpdate* update, base::DictValue stats) { update->clear(); update->Merge(std::move(stats)); } -base::DictValue ReadUsageStatsOrDefault(PrefService *prefs) { +void PrepareUsageStatsForUpdate(ScopedDictPrefUpdate* update) { + base::DictValue normalized; + if (!NormalizeUsageStats(*update, &normalized)) { + StoreUsageStats(update, NewUsageStats(base::Time::Now())); + } +} + +base::DictValue ReadUsageStatsOrDefault(PrefService* prefs) { base::DictValue normalized; if (NormalizeUsageStats(prefs->GetDict(prefs::kDaoAgentUsageStats), &normalized)) { @@ -184,7 +191,7 @@ base::DictValue ReadUsageStatsOrDefault(PrefService *prefs) { return NewUsageStats(base::Time::Now()); } -} // namespace +} // namespace bool IsManagedDaoAgentSetting(std::string_view key) { for (std::string_view candidate : kManagedStringSettings) { @@ -197,7 +204,7 @@ bool IsManagedDaoAgentSetting(std::string_view key) { key == kDaoDreamExcludedDomainsSetting; } -base::DictValue BuildDaoAgentSettingsSnapshot(PrefService *prefs) { +base::DictValue BuildDaoAgentSettingsSnapshot(PrefService* prefs) { base::DictValue snapshot; snapshot.Set("migrationVersion", prefs->GetInteger(prefs::kDaoAgentSettingsMigrationVersion)); @@ -217,12 +224,12 @@ base::DictValue BuildDaoAgentSettingsSnapshot(PrefService *prefs) { return snapshot; } -base::DictValue BuildDaoAgentUsageStats(PrefService *prefs) { +base::DictValue BuildDaoAgentUsageStats(PrefService* prefs) { return prefs ? ReadUsageStatsOrDefault(prefs) : base::DictValue(); } -bool MigrateLegacyDaoAgentUsageStats(PrefService *prefs, - const base::Value *legacy_value) { +bool MigrateLegacyDaoAgentUsageStats(PrefService* prefs, + const base::Value* legacy_value) { if (!prefs || !legacy_value || !legacy_value->is_string() || !prefs->GetDict(prefs::kDaoAgentUsageStats).empty()) { return false; @@ -241,7 +248,7 @@ bool MigrateLegacyDaoAgentUsageStats(PrefService *prefs, return true; } -void RecordDaoAgentApiUsage(PrefService *prefs, double api_calls, +void RecordDaoAgentApiUsage(PrefService* prefs, double api_calls, double prompt_tokens, double completion_tokens, double estimated_cost) { if (!prefs || !std::isfinite(api_calls) || !std::isfinite(prompt_tokens) || @@ -251,38 +258,38 @@ void RecordDaoAgentApiUsage(PrefService *prefs, double api_calls, return; } - base::DictValue stats = ReadUsageStatsOrDefault(prefs); + ScopedDictPrefUpdate update(prefs, prefs::kDaoAgentUsageStats); + PrepareUsageStatsForUpdate(&update); const double next_api_calls = - *stats.FindDouble(kUsageStatsApiCalls) + api_calls; + *update->FindDouble(kUsageStatsApiCalls) + api_calls; const double next_prompt_tokens = - *stats.FindDouble(kUsageStatsPromptTokens) + prompt_tokens; + *update->FindDouble(kUsageStatsPromptTokens) + prompt_tokens; const double next_completion_tokens = - *stats.FindDouble(kUsageStatsCompletionTokens) + completion_tokens; + *update->FindDouble(kUsageStatsCompletionTokens) + completion_tokens; const double next_total_tokens = next_prompt_tokens + next_completion_tokens; const double next_estimated_cost = - *stats.FindDouble(kUsageStatsEstimatedCost) + estimated_cost; + *update->FindDouble(kUsageStatsEstimatedCost) + estimated_cost; if (!std::isfinite(next_api_calls) || !std::isfinite(next_prompt_tokens) || !std::isfinite(next_completion_tokens) || !std::isfinite(next_total_tokens) || !std::isfinite(next_estimated_cost)) { return; } - stats.Set(kUsageStatsApiCalls, next_api_calls); - stats.Set(kUsageStatsPromptTokens, next_prompt_tokens); - stats.Set(kUsageStatsCompletionTokens, next_completion_tokens); - stats.Set(kUsageStatsTotalTokens, next_total_tokens); - stats.Set(kUsageStatsEstimatedCost, next_estimated_cost); - ScopedDictPrefUpdate update(prefs, prefs::kDaoAgentUsageStats); - StoreUsageStats(&update, std::move(stats)); + update->Set(kUsageStatsApiCalls, next_api_calls); + update->Set(kUsageStatsPromptTokens, next_prompt_tokens); + update->Set(kUsageStatsCompletionTokens, next_completion_tokens); + update->Set(kUsageStatsTotalTokens, next_total_tokens); + update->Set(kUsageStatsEstimatedCost, next_estimated_cost); } -void RecordDaoAgentToolUsage(PrefService *prefs, std::string_view tool_name) { +void RecordDaoAgentToolUsage(PrefService* prefs, std::string_view tool_name) { if (!prefs || !IsValidUsageToolName(tool_name)) { return; } - base::DictValue stats = ReadUsageStatsOrDefault(prefs); - base::DictValue *tool_calls = stats.FindDict(kUsageStatsToolCalls); - const base::Value *existing_value = tool_calls->Find(tool_name); + ScopedDictPrefUpdate update(prefs, prefs::kDaoAgentUsageStats); + PrepareUsageStatsForUpdate(&update); + base::DictValue* tool_calls = update->FindDict(kUsageStatsToolCalls); + const base::Value* existing_value = tool_calls->Find(tool_name); if (!existing_value && tool_calls->size() >= kMaxDaoAgentUsageTools) { return; } @@ -292,11 +299,9 @@ void RecordDaoAgentToolUsage(PrefService *prefs, std::string_view tool_name) { return; } tool_calls->Set(tool_name, existing_count + 1.0); - ScopedDictPrefUpdate update(prefs, prefs::kDaoAgentUsageStats); - StoreUsageStats(&update, std::move(stats)); } -void ResetDaoAgentUsageStats(PrefService *prefs, base::Time last_reset) { +void ResetDaoAgentUsageStats(PrefService* prefs, base::Time last_reset) { if (!prefs || last_reset.is_null() || last_reset.InMillisecondsSinceUnixEpoch() < 0) { return; @@ -305,8 +310,8 @@ void ResetDaoAgentUsageStats(PrefService *prefs, base::Time last_reset) { StoreUsageStats(&update, NewUsageStats(last_reset)); } -bool SetDaoAgentSetting(PrefService *prefs, std::string_view key, - const base::Value &value) { +bool SetDaoAgentSetting(PrefService* prefs, std::string_view key, + const base::Value& value) { if (!prefs || !IsManagedDaoAgentSetting(key)) { return false; } @@ -316,7 +321,7 @@ bool SetDaoAgentSetting(PrefService *prefs, std::string_view key, if (!ParseBooleanSetting(value, &enabled)) { return false; } - const char *pref_name = + const char* pref_name = key == kDaoAgentMemoryEnabledSetting ? prefs::kDaoAgentMemoryEnabled : (key == kDaoDreamEnabledSetting ? prefs::kDaoDreamEnabled @@ -341,8 +346,8 @@ bool SetDaoAgentSetting(PrefService *prefs, std::string_view key, } base::DictValue -MigrateLegacyDaoAgentSettings(PrefService *prefs, - const base::DictValue &legacy_values) { +MigrateLegacyDaoAgentSettings(PrefService* prefs, + const base::DictValue& legacy_values) { if (!prefs || prefs->GetInteger(prefs::kDaoAgentSettingsMigrationVersion) >= kDaoAgentSettingsMigrationVersion) { return prefs ? BuildDaoAgentSettingsSnapshot(prefs) : base::DictValue(); @@ -393,7 +398,7 @@ void DaoAgentSettingsHandler::RegisterMessages() { } void DaoAgentSettingsHandler::OnJavascriptAllowed() { - PrefService *prefs = GetPrefs(); + PrefService* prefs = GetPrefs(); if (!prefs || pref_change_registrar_.prefs()) { return; } @@ -414,7 +419,7 @@ void DaoAgentSettingsHandler::OnJavascriptDisallowed() { pref_change_registrar_.Reset(); } -void DaoAgentSettingsHandler::HandleGetSettings(const base::ListValue &args) { +void DaoAgentSettingsHandler::HandleGetSettings(const base::ListValue& args) { AllowJavascript(); if (args.size() != 1 || !args[0].is_string() || !GetPrefs()) { return; @@ -423,7 +428,7 @@ void DaoAgentSettingsHandler::HandleGetSettings(const base::ListValue &args) { } void DaoAgentSettingsHandler::HandleMigrateLegacySettings( - const base::ListValue &args) { + const base::ListValue& args) { AllowJavascript(); if (args.size() != 2 || !args[0].is_string() || !args[1].is_dict() || !GetPrefs()) { @@ -433,7 +438,7 @@ void DaoAgentSettingsHandler::HandleMigrateLegacySettings( args[0], MigrateLegacyDaoAgentSettings(GetPrefs(), args[1].GetDict())); } -void DaoAgentSettingsHandler::HandleSetSetting(const base::ListValue &args) { +void DaoAgentSettingsHandler::HandleSetSetting(const base::ListValue& args) { AllowJavascript(); if (args.size() != 3 || !args[0].is_string() || !args[1].is_string() || !GetPrefs()) { @@ -450,9 +455,9 @@ void DaoAgentSettingsHandler::OnSettingsChanged() { } } -PrefService *DaoAgentSettingsHandler::GetPrefs() { - Profile *profile = Profile::FromWebUI(web_ui()); +PrefService* DaoAgentSettingsHandler::GetPrefs() { + Profile* profile = Profile::FromWebUI(web_ui()); return profile ? profile->GetOriginalProfile()->GetPrefs() : nullptr; } -} // namespace dao +} // namespace dao diff --git a/src/dao/browser/agent/dao_agent_settings_handler.h b/src/dao/browser/agent/dao_agent_settings_handler.h index 687b9f14..7309f07e 100644 --- a/src/dao/browser/agent/dao_agent_settings_handler.h +++ b/src/dao/browser/agent/dao_agent_settings_handler.h @@ -28,28 +28,28 @@ inline constexpr char kDaoDreamExcludedDomainsSetting[] = "dao_dream_excluded_domains"; bool IsManagedDaoAgentSetting(std::string_view key); -base::DictValue BuildDaoAgentSettingsSnapshot(PrefService *prefs); -base::DictValue BuildDaoAgentUsageStats(PrefService *prefs); -bool MigrateLegacyDaoAgentUsageStats(PrefService *prefs, - const base::Value *legacy_value); -void RecordDaoAgentApiUsage(PrefService *prefs, double api_calls, +base::DictValue BuildDaoAgentSettingsSnapshot(PrefService* prefs); +base::DictValue BuildDaoAgentUsageStats(PrefService* prefs); +bool MigrateLegacyDaoAgentUsageStats(PrefService* prefs, + const base::Value* legacy_value); +void RecordDaoAgentApiUsage(PrefService* prefs, double api_calls, double prompt_tokens, double completion_tokens, double estimated_cost); -void RecordDaoAgentToolUsage(PrefService *prefs, std::string_view tool_name); -void ResetDaoAgentUsageStats(PrefService *prefs, base::Time last_reset); -bool SetDaoAgentSetting(PrefService *prefs, std::string_view key, - const base::Value &value); +void RecordDaoAgentToolUsage(PrefService* prefs, std::string_view tool_name); +void ResetDaoAgentUsageStats(PrefService* prefs, base::Time last_reset); +bool SetDaoAgentSetting(PrefService* prefs, std::string_view key, + const base::Value& value); base::DictValue -MigrateLegacyDaoAgentSettings(PrefService *prefs, - const base::DictValue &legacy_values); +MigrateLegacyDaoAgentSettings(PrefService* prefs, + const base::DictValue& legacy_values); // Shared by dao://agent and dao://settings so both origins observe one // Profile-scoped source of truth. class DaoAgentSettingsHandler : public content::WebUIMessageHandler { public: DaoAgentSettingsHandler(); - DaoAgentSettingsHandler(const DaoAgentSettingsHandler &) = delete; - DaoAgentSettingsHandler &operator=(const DaoAgentSettingsHandler &) = delete; + DaoAgentSettingsHandler(const DaoAgentSettingsHandler&) = delete; + DaoAgentSettingsHandler& operator=(const DaoAgentSettingsHandler&) = delete; ~DaoAgentSettingsHandler() override; // content::WebUIMessageHandler: @@ -58,15 +58,15 @@ class DaoAgentSettingsHandler : public content::WebUIMessageHandler { void OnJavascriptDisallowed() override; private: - void HandleGetSettings(const base::ListValue &args); - void HandleMigrateLegacySettings(const base::ListValue &args); - void HandleSetSetting(const base::ListValue &args); + void HandleGetSettings(const base::ListValue& args); + void HandleMigrateLegacySettings(const base::ListValue& args); + void HandleSetSetting(const base::ListValue& args); void OnSettingsChanged(); - PrefService *GetPrefs(); + PrefService* GetPrefs(); PrefChangeRegistrar pref_change_registrar_; }; -} // namespace dao +} // namespace dao -#endif // DAO_BROWSER_AGENT_DAO_AGENT_SETTINGS_HANDLER_H_ +#endif // DAO_BROWSER_AGENT_DAO_AGENT_SETTINGS_HANDLER_H_ diff --git a/src/dao/browser/agent/dao_agent_settings_handler_unittest.cc b/src/dao/browser/agent/dao_agent_settings_handler_unittest.cc index 77a75caf..7ebb98d6 100644 --- a/src/dao/browser/agent/dao_agent_settings_handler_unittest.cc +++ b/src/dao/browser/agent/dao_agent_settings_handler_unittest.cc @@ -88,7 +88,7 @@ TEST_F(DaoAgentSettingsHandlerTest, MigratesUsageStatsIntoSettingsSnapshot) { const base::DictValue snapshot = MigrateLegacyDaoAgentSettings(&prefs_, legacy); - const base::DictValue *usage_stats = snapshot.FindDict("usageStats"); + const base::DictValue* usage_stats = snapshot.FindDict("usageStats"); ASSERT_TRUE(usage_stats); EXPECT_EQ(1.0, usage_stats->FindDouble("apiCalls").value_or(-1)); EXPECT_FALSE(snapshot.FindDict("values")->contains("usageStats")); @@ -142,6 +142,21 @@ TEST_F(DaoAgentSettingsHandlerTest, AccumulatesAndResetsUsageStats) { EXPECT_EQ(987654.0, stats.FindDouble("lastReset").value_or(-1)); } +TEST_F(DaoAgentSettingsHandlerTest, ToolUsagePreservesExistingApiUsage) { + RecordDaoAgentApiUsage(&prefs_, 3.0, 20.0, 10.0, 1.5); + RecordDaoAgentToolUsage(&prefs_, "web_search"); + + const base::DictValue stats = BuildDaoAgentUsageStats(&prefs_); + EXPECT_EQ(3.0, stats.FindDouble("apiCalls").value_or(-1)); + EXPECT_EQ(20.0, stats.FindDouble("promptTokens").value_or(-1)); + EXPECT_EQ(10.0, stats.FindDouble("completionTokens").value_or(-1)); + EXPECT_EQ(30.0, stats.FindDouble("totalTokens").value_or(-1)); + EXPECT_EQ(1.5, stats.FindDouble("estimatedCost").value_or(-1)); + ASSERT_TRUE(stats.FindDict("toolCalls")); + EXPECT_EQ(1.0, + stats.FindDict("toolCalls")->FindDouble("web_search").value_or(-1)); +} + TEST_F(DaoAgentSettingsHandlerTest, MigratesKnownMissingValuesOnlyOnce) { { ScopedDictPrefUpdate update(&prefs_, prefs::kDaoAgentSettings); @@ -156,7 +171,7 @@ TEST_F(DaoAgentSettingsHandlerTest, MigratesKnownMissingValuesOnlyOnce) { EXPECT_EQ(kDaoAgentSettingsMigrationVersion, snapshot.FindInt("migrationVersion").value_or(-1)); - const base::DictValue *values = snapshot.FindDict("values"); + const base::DictValue* values = snapshot.FindDict("values"); ASSERT_TRUE(values); EXPECT_EQ("settings-model", *values->FindString("dao_agent_model")); EXPECT_EQ("legacy-key", *values->FindString("dao_agent_api_key")); @@ -183,12 +198,12 @@ TEST_F(DaoAgentSettingsHandlerTest, NormalizesDreamExcludedDomains) { &prefs_, kDaoDreamExcludedDomainsSetting, base::Value(R"(["HTTPS://Example.com/path","sub.example.com"] )"))); - const base::ListValue &domains = + const base::ListValue& domains = prefs_.GetList(prefs::kDaoDreamExcludedDomains); ASSERT_EQ(2u, domains.size()); EXPECT_EQ("example.com", domains[0].GetString()); EXPECT_EQ("sub.example.com", domains[1].GetString()); } -} // namespace -} // namespace dao +} // namespace +} // namespace dao diff --git a/src/dao/browser/dao_pref_names.cc b/src/dao/browser/dao_pref_names.cc index f81fe0a1..515ff822 100644 --- a/src/dao/browser/dao_pref_names.cc +++ b/src/dao/browser/dao_pref_names.cc @@ -19,11 +19,11 @@ namespace dao::prefs { -void RegisterLocalStatePrefs(PrefRegistrySimple *registry) { +void RegisterLocalStatePrefs(PrefRegistrySimple* registry) { registry->RegisterBooleanPref(kDaoMcpServerEnabled, false); } -void RegisterProfilePrefs(user_prefs::PrefRegistrySyncable *registry) { +void RegisterProfilePrefs(user_prefs::PrefRegistrySyncable* registry) { registry->RegisterBooleanPref(kDaoAgentMemoryEnabled, false); registry->RegisterDictionaryPref(kDaoAgentSettings); registry->RegisterIntegerPref(kDaoAgentSettingsMigrationVersion, 0); @@ -45,42 +45,42 @@ void RegisterProfilePrefs(user_prefs::PrefRegistrySyncable *registry) { registry->RegisterListPref(kDaoDreamExcludedDomains); } -} // namespace dao::prefs +} // namespace dao::prefs namespace dao { namespace { -Profile *GetStorageProfile(Profile *profile) { +Profile* GetStorageProfile(Profile* profile) { return profile ? profile->GetOriginalProfile() : nullptr; } -bool UsesSameStorageProfile(Profile *lhs, Profile *rhs) { +bool UsesSameStorageProfile(Profile* lhs, Profile* rhs) { return GetStorageProfile(lhs) == GetStorageProfile(rhs); } -} // namespace +} // namespace bool IsSystemDarkMode() { - ui::NativeTheme *theme = ui::NativeTheme::GetInstanceForNativeUi(); + ui::NativeTheme* theme = ui::NativeTheme::GetInstanceForNativeUi(); return theme && theme->preferred_color_scheme() == ui::NativeTheme::PreferredColorScheme::kDark; } -bool IsForceDarkModeUserEnabled(Profile *profile) { - Profile *storage_profile = GetStorageProfile(profile); +bool IsForceDarkModeUserEnabled(Profile* profile) { + Profile* storage_profile = GetStorageProfile(profile); return storage_profile && storage_profile->GetPrefs()->GetBoolean( prefs::kDaoForceDarkModeEnabled); } bool IsForceDarkModeAvailable() { return IsSystemDarkMode(); } -bool IsForceDarkModeEffective(Profile *profile) { +bool IsForceDarkModeEffective(Profile* profile) { return IsForceDarkModeAvailable() && IsForceDarkModeUserEnabled(profile); } -void SetForceDarkModeUserEnabled(Profile *profile, bool enabled) { - Profile *storage_profile = GetStorageProfile(profile); +void SetForceDarkModeUserEnabled(Profile* profile, bool enabled) { + Profile* storage_profile = GetStorageProfile(profile); if (!storage_profile) { return; } @@ -90,7 +90,7 @@ void SetForceDarkModeUserEnabled(Profile *profile, bool enabled) { } void ApplyForceDarkModePreferences( - Profile *profile, blink::web_pref::WebPreferences *web_preferences) { + Profile* profile, blink::web_pref::WebPreferences* web_preferences) { if (!web_preferences) { return; } @@ -107,28 +107,28 @@ void ApplyForceDarkModePreferences( blink::mojom::PreferredColorScheme::kDark; } -void NotifyForceDarkModeChanged(Profile *profile) { - Profile *storage_profile = GetStorageProfile(profile); +void NotifyForceDarkModeChanged(Profile* profile) { + Profile* storage_profile = GetStorageProfile(profile); if (!storage_profile) { return; } - for (BrowserWindowInterface *browser_window : + for (BrowserWindowInterface* browser_window : GetAllBrowserWindowInterfaces()) { - Browser *browser = + Browser* browser = browser_window ? browser_window->GetBrowserForMigrationOnly() : nullptr; if (!browser || !UsesSameStorageProfile(browser->profile(), storage_profile)) { continue; } - TabStripModel *tab_strip_model = browser->tab_strip_model(); + TabStripModel* tab_strip_model = browser->tab_strip_model(); if (!tab_strip_model) { continue; } for (int i = 0; i < tab_strip_model->count(); ++i) { - content::WebContents *contents = tab_strip_model->GetWebContentsAt(i); + content::WebContents* contents = tab_strip_model->GetWebContentsAt(i); if (contents) { contents->OnWebPreferencesChanged(); } @@ -136,4 +136,4 @@ void NotifyForceDarkModeChanged(Profile *profile) { } } -} // namespace dao +} // namespace dao diff --git a/src/dao/browser/dao_pref_names.h b/src/dao/browser/dao_pref_names.h index da384a53..044414ea 100644 --- a/src/dao/browser/dao_pref_names.h +++ b/src/dao/browser/dao_pref_names.h @@ -18,8 +18,8 @@ class PrefRegistrySyncable; namespace dao::prefs { -void RegisterLocalStatePrefs(PrefRegistrySimple *registry); -void RegisterProfilePrefs(user_prefs::PrefRegistrySyncable *registry); +void RegisterLocalStatePrefs(PrefRegistrySimple* registry); +void RegisterProfilePrefs(user_prefs::PrefRegistrySyncable* registry); // Global local-state pref controlling the owner-only local MCP endpoint. // The service is disabled by default and remains independent of Dao Agent. @@ -105,20 +105,20 @@ inline constexpr char kDaoDreamDebug[] = "dao.dream_debug"; // collection. A value excludes the domain and its subdomains. inline constexpr char kDaoDreamExcludedDomains[] = "dao.dream_excluded_domains"; -} // namespace dao::prefs +} // namespace dao::prefs namespace dao { bool IsSystemDarkMode(); -bool IsForceDarkModeUserEnabled(Profile *profile); +bool IsForceDarkModeUserEnabled(Profile* profile); bool IsForceDarkModeAvailable(); -bool IsForceDarkModeEffective(Profile *profile); +bool IsForceDarkModeEffective(Profile* profile); -void SetForceDarkModeUserEnabled(Profile *profile, bool enabled); +void SetForceDarkModeUserEnabled(Profile* profile, bool enabled); void ApplyForceDarkModePreferences( - Profile *profile, blink::web_pref::WebPreferences *web_preferences); -void NotifyForceDarkModeChanged(Profile *profile); + Profile* profile, blink::web_pref::WebPreferences* web_preferences); +void NotifyForceDarkModeChanged(Profile* profile); -} // namespace dao +} // namespace dao -#endif // DAO_BROWSER_DAO_PREF_NAMES_H_ +#endif // DAO_BROWSER_DAO_PREF_NAMES_H_ From 9fa314cbe43ce7eabc2411c7c4740f2aaa2ba455 Mon Sep 17 00:00:00 2001 From: moonrailgun Date: Fri, 7 Aug 2026 11:20:03 +0800 Subject: [PATCH 03/13] feat(agent): expose settings management --- .../settings_redesign_contract.test.ts | 25 ++ .../agent/dao_agent_settings_handler.cc | 254 +++++++++++++++++- .../agent/dao_agent_settings_handler.h | 11 + 3 files changed, 289 insertions(+), 1 deletion(-) diff --git a/scripts/commands/__tests__/settings_redesign_contract.test.ts b/scripts/commands/__tests__/settings_redesign_contract.test.ts index 1412c868..333b0e88 100644 --- a/scripts/commands/__tests__/settings_redesign_contract.test.ts +++ b/scripts/commands/__tests__/settings_redesign_contract.test.ts @@ -156,4 +156,29 @@ describe('settings continuous overview contract', () => { expect(tool_usage).toMatch( /ScopedDictPrefUpdate update[\s\S]*?update->FindDict/); }); + + it('exposes only the restricted Agent management surface', () => { + const handler = readDaoSource( + 'src/dao/browser/agent/dao_agent_settings_handler.cc'); + for (const message of [ + 'getDaoAgentMemorySummary', + 'clearAllDaoAgentMemory', + 'getDaoAgentWorkspaceSummary', + 'openDaoAgentWorkspace', + 'getDaoAgentUsageStats', + 'resetDaoAgentUsageStats', + ]) { + expect(handler).toContain(`"${message}"`); + } + for (const forbidden of [ + 'workspaceRead', + 'workspaceWrite', + 'workspaceEdit', + 'workspaceApplyPatch', + 'workspaceList', + 'workspaceDownload', + ]) { + expect(handler).not.toContain(`"${forbidden}"`); + } + }); }); diff --git a/src/dao/browser/agent/dao_agent_settings_handler.cc b/src/dao/browser/agent/dao_agent_settings_handler.cc index 2ac158a1..e9f2fc24 100644 --- a/src/dao/browser/agent/dao_agent_settings_handler.cc +++ b/src/dao/browser/agent/dao_agent_settings_handler.cc @@ -9,6 +9,7 @@ #include #include #include +#include #include "base/functional/bind.h" #include "base/json/json_reader.h" @@ -18,6 +19,10 @@ #include "components/prefs/pref_service.h" #include "components/prefs/scoped_user_pref_update.h" #include "content/public/browser/web_ui.h" +#include "dao/browser/agent/dao_agent_memory_service.h" +#include "dao/browser/agent/dao_agent_memory_service_factory.h" +#include "dao/browser/agent/dao_agent_workspace_service.h" +#include "dao/browser/agent/dao_agent_workspace_service_factory.h" #include "dao/browser/agent/dao_dream_domain_utils.h" #include "dao/browser/dao_pref_names.h" @@ -25,6 +30,7 @@ namespace dao { namespace { constexpr char kSettingsChangedEvent[] = "dao-agent-settings-changed"; +constexpr char kUsageStatsChangedEvent[] = "dao-agent-usage-stats-changed"; constexpr size_t kMaxSettingValueBytes = 256 * 1024; constexpr size_t kMaxSettingsBytes = 512 * 1024; @@ -191,6 +197,37 @@ base::DictValue ReadUsageStatsOrDefault(PrefService* prefs) { return NewUsageStats(base::Time::Now()); } +base::DictValue BuildMemorySummary(const StorageStats& stats) { + base::DictValue summary; + summary.Set("totalSize", static_cast(stats.total_size_bytes)); + summary.Set("conversationCount", stats.conversation_count); + summary.Set("episodeCount", stats.episode_count); + summary.Set("preferenceCount", stats.preference_count); + return summary; +} + +base::DictValue BuildWorkspaceSummary( + const DaoAgentWorkspaceService::UsageSnapshot& usage, + const std::vector& entries) { + base::DictValue summary; + summary.Set("root", usage.root.AsUTF8Unsafe()); + summary.Set("usedBytes", static_cast(usage.used_bytes)); + summary.Set("capBytes", static_cast(usage.cap_bytes)); + summary.Set("fileCount", static_cast(usage.file_count)); + summary.Set("fileCountCap", static_cast(usage.file_count_cap)); + + base::ListValue recent_activity; + for (const AuditEntry& entry : entries) { + base::DictValue activity; + activity.Set("timestamp", entry.ts); + activity.Set("operation", entry.op); + activity.Set("path", entry.path); + recent_activity.Append(std::move(activity)); + } + summary.Set("recentActivity", std::move(recent_activity)); + return summary; +} + } // namespace bool IsManagedDaoAgentSetting(std::string_view key) { @@ -395,6 +432,38 @@ void DaoAgentSettingsHandler::RegisterMessages() { "setDaoAgentSetting", base::BindRepeating(&DaoAgentSettingsHandler::HandleSetSetting, base::Unretained(this))); + web_ui()->RegisterMessageCallback( + "getDaoAgentMemorySummary", + base::BindRepeating(&DaoAgentSettingsHandler::HandleGetMemorySummary, + base::Unretained(this))); + web_ui()->RegisterMessageCallback( + "clearAllDaoAgentMemory", + base::BindRepeating(&DaoAgentSettingsHandler::HandleClearAllMemory, + base::Unretained(this))); + web_ui()->RegisterMessageCallback( + "getDaoAgentWorkspaceSummary", + base::BindRepeating(&DaoAgentSettingsHandler::HandleGetWorkspaceSummary, + base::Unretained(this))); + web_ui()->RegisterMessageCallback( + "openDaoAgentWorkspace", + base::BindRepeating(&DaoAgentSettingsHandler::HandleOpenWorkspace, + base::Unretained(this))); + web_ui()->RegisterMessageCallback( + "getDaoAgentUsageStats", + base::BindRepeating(&DaoAgentSettingsHandler::HandleGetUsageStats, + base::Unretained(this))); + web_ui()->RegisterMessageCallback( + "resetDaoAgentUsageStats", + base::BindRepeating(&DaoAgentSettingsHandler::HandleResetUsageStats, + base::Unretained(this))); + web_ui()->RegisterMessageCallback( + "recordDaoAgentApiUsage", + base::BindRepeating(&DaoAgentSettingsHandler::HandleRecordApiUsage, + base::Unretained(this))); + web_ui()->RegisterMessageCallback( + "recordDaoAgentToolUsage", + base::BindRepeating(&DaoAgentSettingsHandler::HandleRecordToolUsage, + base::Unretained(this))); } void DaoAgentSettingsHandler::OnJavascriptAllowed() { @@ -408,15 +477,19 @@ void DaoAgentSettingsHandler::OnJavascriptAllowed() { pref_change_registrar_.Add(prefs::kDaoAgentSettings, callback); pref_change_registrar_.Add(prefs::kDaoAgentSettingsMigrationVersion, callback); - pref_change_registrar_.Add(prefs::kDaoAgentUsageStats, callback); pref_change_registrar_.Add(prefs::kDaoAgentMemoryEnabled, callback); pref_change_registrar_.Add(prefs::kDaoDreamEnabled, callback); pref_change_registrar_.Add(prefs::kDaoDreamDebug, callback); pref_change_registrar_.Add(prefs::kDaoDreamExcludedDomains, callback); + pref_change_registrar_.Add( + prefs::kDaoAgentUsageStats, + base::BindRepeating(&DaoAgentSettingsHandler::OnUsageStatsChanged, + base::Unretained(this))); } void DaoAgentSettingsHandler::OnJavascriptDisallowed() { pref_change_registrar_.Reset(); + weak_factory_.InvalidateWeakPtrs(); } void DaoAgentSettingsHandler::HandleGetSettings(const base::ListValue& args) { @@ -448,6 +521,178 @@ void DaoAgentSettingsHandler::HandleSetSetting(const base::ListValue& args) { args[0], SetDaoAgentSetting(GetPrefs(), args[1].GetString(), args[2])); } +void DaoAgentSettingsHandler::HandleGetMemorySummary( + const base::ListValue& args) { + AllowJavascript(); + if (args.size() != 1 || !args[0].is_string()) { + return; + } + const std::string callback_id = args[0].GetString(); + Profile* profile = Profile::FromWebUI(web_ui()); + DaoAgentMemoryService* service = + profile ? DaoAgentMemoryServiceFactory::GetForProfile(profile) : nullptr; + if (!service) { + ResolveJavascriptCallback(args[0], base::DictValue()); + return; + } + service->GetStorageStats(base::BindOnce( + [](base::WeakPtr handler, + std::string callback_id, StorageStats stats) { + if (handler) { + handler->ResolveJavascriptCallback(base::Value(callback_id), + BuildMemorySummary(stats)); + } + }, + weak_factory_.GetWeakPtr(), callback_id)); +} + +void DaoAgentSettingsHandler::HandleClearAllMemory( + const base::ListValue& args) { + AllowJavascript(); + if (args.size() != 1 || !args[0].is_string()) { + return; + } + const std::string callback_id = args[0].GetString(); + Profile* profile = Profile::FromWebUI(web_ui()); + DaoAgentMemoryService* service = + profile ? DaoAgentMemoryServiceFactory::GetForProfile(profile) : nullptr; + if (!service) { + ResolveJavascriptCallback(args[0], base::Value(false)); + return; + } + service->ClearAll(base::BindOnce( + [](base::WeakPtr handler, + std::string callback_id, bool success) { + if (handler) { + handler->ResolveJavascriptCallback(base::Value(callback_id), + base::Value(success)); + } + }, + weak_factory_.GetWeakPtr(), callback_id)); +} + +void DaoAgentSettingsHandler::HandleGetWorkspaceSummary( + const base::ListValue& args) { + AllowJavascript(); + if (args.size() != 1 || !args[0].is_string()) { + return; + } + const std::string callback_id = args[0].GetString(); + Profile* profile = Profile::FromWebUI(web_ui()); + DaoAgentWorkspaceService* service = + profile ? DaoAgentWorkspaceServiceFactory::GetForProfile(profile) + : nullptr; + if (!service) { + ResolveJavascriptCallback(args[0], base::DictValue()); + return; + } + service->GetUsageInfo(base::BindOnce( + [](base::WeakPtr handler, + std::string callback_id, + DaoAgentWorkspaceService::UsageSnapshot usage) { + if (!handler) { + return; + } + Profile* profile = Profile::FromWebUI(handler->web_ui()); + DaoAgentWorkspaceService* service = + profile ? DaoAgentWorkspaceServiceFactory::GetForProfile(profile) + : nullptr; + if (!service) { + handler->ResolveJavascriptCallback(base::Value(callback_id), + base::DictValue()); + return; + } + service->GetRecentAuditAsync(base::BindOnce( + [](base::WeakPtr handler, + std::string callback_id, + DaoAgentWorkspaceService::UsageSnapshot usage, + std::vector entries) { + if (handler) { + handler->ResolveJavascriptCallback( + base::Value(callback_id), + BuildWorkspaceSummary(usage, entries)); + } + }, + handler, callback_id, std::move(usage))); + }, + weak_factory_.GetWeakPtr(), callback_id)); +} + +void DaoAgentSettingsHandler::HandleOpenWorkspace(const base::ListValue& args) { + AllowJavascript(); + if (args.size() != 1 || !args[0].is_string()) { + return; + } + Profile* profile = Profile::FromWebUI(web_ui()); + DaoAgentWorkspaceService* service = + profile ? DaoAgentWorkspaceServiceFactory::GetForProfile(profile) + : nullptr; + if (!service) { + ResolveJavascriptCallback(args[0], base::Value(false)); + return; + } + service->OpenInFileManager(); + ResolveJavascriptCallback(args[0], base::Value(true)); +} + +void DaoAgentSettingsHandler::HandleGetUsageStats(const base::ListValue& args) { + AllowJavascript(); + if (args.size() != 1 || !args[0].is_string()) { + return; + } + ResolveJavascriptCallback(args[0], BuildDaoAgentUsageStats(GetPrefs())); +} + +void DaoAgentSettingsHandler::HandleResetUsageStats( + const base::ListValue& args) { + AllowJavascript(); + PrefService* prefs = GetPrefs(); + if (args.size() != 1 || !args[0].is_string() || !prefs) { + if (args.size() == 1 && args[0].is_string()) { + ResolveJavascriptCallback(args[0], base::Value(false)); + } + return; + } + ResetDaoAgentUsageStats(prefs, base::Time::Now()); + ResolveJavascriptCallback(args[0], base::Value(true)); +} + +void DaoAgentSettingsHandler::HandleRecordApiUsage( + const base::ListValue& args) { + AllowJavascript(); + PrefService* prefs = GetPrefs(); + if (args.size() != 4 || !prefs) { + return; + } + const std::optional prompt_tokens = + ReadNonNegativeFiniteNumber(&args[0]); + const std::optional completion_tokens = + ReadNonNegativeFiniteNumber(&args[1]); + const std::optional prompt_cost_per_million = + ReadNonNegativeFiniteNumber(&args[2]); + const std::optional completion_cost_per_million = + ReadNonNegativeFiniteNumber(&args[3]); + if (!prompt_tokens || !completion_tokens || !prompt_cost_per_million || + !completion_cost_per_million) { + return; + } + const double estimated_cost = + (*prompt_tokens * *prompt_cost_per_million + + *completion_tokens * *completion_cost_per_million) / + 1'000'000.0; + RecordDaoAgentApiUsage(prefs, 1.0, *prompt_tokens, *completion_tokens, + estimated_cost); +} + +void DaoAgentSettingsHandler::HandleRecordToolUsage( + const base::ListValue& args) { + AllowJavascript(); + if (args.size() != 1 || !args[0].is_string()) { + return; + } + RecordDaoAgentToolUsage(GetPrefs(), args[0].GetString()); +} + void DaoAgentSettingsHandler::OnSettingsChanged() { if (IsJavascriptAllowed() && GetPrefs()) { FireWebUIListener(kSettingsChangedEvent, @@ -455,6 +700,13 @@ void DaoAgentSettingsHandler::OnSettingsChanged() { } } +void DaoAgentSettingsHandler::OnUsageStatsChanged() { + if (IsJavascriptAllowed() && GetPrefs()) { + FireWebUIListener(kUsageStatsChangedEvent, + BuildDaoAgentUsageStats(GetPrefs())); + } +} + PrefService* DaoAgentSettingsHandler::GetPrefs() { Profile* profile = Profile::FromWebUI(web_ui()); return profile ? profile->GetOriginalProfile()->GetPrefs() : nullptr; diff --git a/src/dao/browser/agent/dao_agent_settings_handler.h b/src/dao/browser/agent/dao_agent_settings_handler.h index 7309f07e..df22a988 100644 --- a/src/dao/browser/agent/dao_agent_settings_handler.h +++ b/src/dao/browser/agent/dao_agent_settings_handler.h @@ -8,6 +8,7 @@ #include #include +#include "base/memory/weak_ptr.h" #include "base/time/time.h" #include "base/values.h" #include "components/prefs/pref_change_registrar.h" @@ -61,10 +62,20 @@ class DaoAgentSettingsHandler : public content::WebUIMessageHandler { void HandleGetSettings(const base::ListValue& args); void HandleMigrateLegacySettings(const base::ListValue& args); void HandleSetSetting(const base::ListValue& args); + void HandleGetMemorySummary(const base::ListValue& args); + void HandleClearAllMemory(const base::ListValue& args); + void HandleGetWorkspaceSummary(const base::ListValue& args); + void HandleOpenWorkspace(const base::ListValue& args); + void HandleGetUsageStats(const base::ListValue& args); + void HandleResetUsageStats(const base::ListValue& args); + void HandleRecordApiUsage(const base::ListValue& args); + void HandleRecordToolUsage(const base::ListValue& args); void OnSettingsChanged(); + void OnUsageStatsChanged(); PrefService* GetPrefs(); PrefChangeRegistrar pref_change_registrar_; + base::WeakPtrFactory weak_factory_{this}; }; } // namespace dao From 3a1555763f13f3b4ced510f81e7ee930a883e8fd Mon Sep 17 00:00:00 2001 From: moonrailgun Date: Fri, 7 Aug 2026 11:33:28 +0800 Subject: [PATCH 04/13] feat(agent): sync usage statistics --- .../agent_bridge_call_native.test.ts | 38 ++++++- .../__tests__/agent_settings_sync.test.ts | 105 ++++++++++++++++++ .../ui/webui/resources/agent/agent_bridge.ts | 39 ++----- .../agent/agent_settings_native_bridge.ts | 79 +++++++++++++ .../resources/agent/agent_settings_sync.ts | 96 ++++++++++++++++ 5 files changed, 325 insertions(+), 32 deletions(-) create mode 100644 src/dao/browser/ui/webui/resources/agent/__tests__/agent_settings_sync.test.ts create mode 100644 src/dao/browser/ui/webui/resources/agent/agent_settings_native_bridge.ts create mode 100644 src/dao/browser/ui/webui/resources/agent/agent_settings_sync.ts diff --git a/src/dao/browser/ui/webui/resources/agent/__tests__/agent_bridge_call_native.test.ts b/src/dao/browser/ui/webui/resources/agent/__tests__/agent_bridge_call_native.test.ts index 48781fce..ba422776 100644 --- a/src/dao/browser/ui/webui/resources/agent/__tests__/agent_bridge_call_native.test.ts +++ b/src/dao/browser/ui/webui/resources/agent/__tests__/agent_bridge_call_native.test.ts @@ -4,7 +4,16 @@ import {afterEach, describe, expect, it, vi} from 'vitest'; -import {callNative, cr, executeTool} from '../agent_bridge.js'; +import { + applyAgentStatsSnapshot, + callNative, + cr, + executeTool, + getAgentStats, + recordApiCall, + recordToolCall, + resetAgentStats, +} from '../agent_bridge.js'; describe('callNative', () => { afterEach(() => { @@ -40,6 +49,33 @@ describe('callNative', () => { await expect(promise).resolves.toBe(true); }); + it('forwards usage records to native and replaces the immediate cache', () => { + const send = vi.fn(); + vi.stubGlobal('chrome', {send}); + const setItem = vi.spyOn(Storage.prototype, 'setItem'); + const profileStats = { + apiCalls: 7, + toolCalls: {web_search: 3}, + promptTokens: 100, + completionTokens: 25, + totalTokens: 125, + estimatedCost: 0.0002, + lastReset: 1720000000000, + }; + + recordApiCall(10, 5, 1, 2); + expect(send).toHaveBeenCalledWith( + 'recordDaoAgentApiUsage', [10, 5, 1, 2]); + recordToolCall('web_search'); + expect(send).toHaveBeenCalledWith( + 'recordDaoAgentToolUsage', ['web_search']); + resetAgentStats(); + expect(send).toHaveBeenCalledWith('resetDaoAgentUsageStats'); + applyAgentStatsSnapshot(profileStats); + expect(getAgentStats()).toEqual(profileStats); + expect(setItem).not.toHaveBeenCalled(); + }); + it('cancels an in-flight native tool once and ignores a late response', async () => { const send = vi.fn(); diff --git a/src/dao/browser/ui/webui/resources/agent/__tests__/agent_settings_sync.test.ts b/src/dao/browser/ui/webui/resources/agent/__tests__/agent_settings_sync.test.ts new file mode 100644 index 00000000..8840fe65 --- /dev/null +++ b/src/dao/browser/ui/webui/resources/agent/__tests__/agent_settings_sync.test.ts @@ -0,0 +1,105 @@ +// Copyright 2026 Dao Browser Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import {beforeEach, describe, expect, it, vi} from 'vitest'; + +import { + applyAgentSettingsSnapshot, + collectLegacyAgentSettings, + initializeAgentSettingsSync, + type AgentSettingsBridge, +} from '../agent_settings_sync.js'; + +const usageStats = { + apiCalls: 0, + toolCalls: {}, + promptTokens: 0, + completionTokens: 0, + totalTokens: 0, + estimatedCost: 0, + lastReset: 1720000000000, +}; + +describe('agent settings sync', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('collects only durable settings owned by the unified settings page', () => { + localStorage.setItem('dao_agent_active_provider', 'anthropic'); + localStorage.setItem('dao_agent_soul', 'Be concise.'); + localStorage.setItem('dao_disabled_tools', '["execute_script"]'); + localStorage.setItem('dao_agent_stats', '{"apiCalls":9}'); + localStorage.setItem('dao_agent_runtime_session', 'ignored'); + localStorage.setItem('dao_reusable_element_contexts', '[{"id":1}]'); + + expect(collectLegacyAgentSettings(localStorage)).toEqual({ + dao_agent_active_provider: 'anthropic', + dao_agent_soul: 'Be concise.', + dao_disabled_tools: '["execute_script"]', + dao_agent_stats: '{"apiCalls":9}', + }); + expect(collectLegacyAgentSettings(localStorage)) + .not.toHaveProperty('dao_agent_runtime_session'); + }); + + it('migrates legacy values once and applies the canonical snapshot', async () => { + localStorage.setItem('dao_agent_active_provider', 'anthropic'); + localStorage.setItem('dao_agent_soul', 'Legacy soul'); + const bridge: AgentSettingsBridge = { + getSnapshot: vi.fn().mockResolvedValue({ + migrationVersion: 1, + values: {}, + usageStats, + }), + migrateLegacy: vi.fn().mockResolvedValue({ + migrationVersion: 2, + values: { + dao_agent_active_provider: 'anthropic', + dao_agent_soul: 'Canonical soul', + }, + usageStats, + }), + }; + + await initializeAgentSettingsSync(bridge, localStorage); + + expect(bridge.migrateLegacy).toHaveBeenCalledWith({ + dao_agent_active_provider: 'anthropic', + dao_agent_soul: 'Legacy soul', + }); + expect(localStorage.getItem('dao_agent_soul')).toBe('Canonical soul'); + }); + + it('does not re-migrate a version 2 snapshot', async () => { + const bridge: AgentSettingsBridge = { + getSnapshot: vi.fn().mockResolvedValue({ + migrationVersion: 2, + values: {}, + usageStats, + }), + migrateLegacy: vi.fn(), + }; + + await initializeAgentSettingsSync(bridge, localStorage); + + expect(bridge.migrateLegacy).not.toHaveBeenCalled(); + }); + + it('removes stale cached settings missing from the canonical snapshot', () => { + localStorage.setItem('dao_agent_active_provider', 'openai'); + localStorage.setItem('dao_jina_api_key', 'stale-secret'); + localStorage.setItem('unrelated_runtime_state', 'keep'); + + applyAgentSettingsSnapshot({ + migrationVersion: 2, + values: {dao_agent_active_provider: 'google'}, + usageStats, + }, localStorage); + + expect(localStorage.getItem('dao_agent_active_provider')).toBe('google'); + expect(localStorage.getItem('dao_jina_api_key')).toBeNull(); + expect(localStorage.getItem('unrelated_runtime_state')).toBe('keep'); + }); +}); diff --git a/src/dao/browser/ui/webui/resources/agent/agent_bridge.ts b/src/dao/browser/ui/webui/resources/agent/agent_bridge.ts index 3191231b..dd726e64 100644 --- a/src/dao/browser/ui/webui/resources/agent/agent_bridge.ts +++ b/src/dao/browser/ui/webui/resources/agent/agent_bridge.ts @@ -1419,8 +1419,6 @@ export interface AgentStats { lastReset: number; } -const STATS_KEY = 'dao_agent_stats'; - function defaultStats(): AgentStats { return { apiCalls: 0, toolCalls: {}, promptTokens: 0, @@ -1429,33 +1427,10 @@ function defaultStats(): AgentStats { }; } -function loadStats(): AgentStats { - const base = defaultStats(); - try { - const raw = localStorage.getItem(STATS_KEY); - if (!raw) return base; - const parsed = JSON.parse(raw) as Partial; - // Merge with defaults so missing fields from older schemas don't - // produce NaN on accumulation. - return { - apiCalls: Number(parsed.apiCalls) || 0, - toolCalls: parsed.toolCalls && typeof parsed.toolCalls === 'object' ? - {...parsed.toolCalls} : {}, - promptTokens: Number(parsed.promptTokens) || 0, - completionTokens: Number(parsed.completionTokens) || 0, - totalTokens: Number(parsed.totalTokens) || 0, - estimatedCost: Number(parsed.estimatedCost) || 0, - lastReset: Number(parsed.lastReset) || base.lastReset, - }; - } catch (_) { - return base; - } -} - -let cachedStats: AgentStats = loadStats(); +let cachedStats: AgentStats = defaultStats(); -function saveStats() { - localStorage.setItem(STATS_KEY, JSON.stringify(cachedStats)); +export function applyAgentStatsSnapshot(stats: AgentStats): void { + cachedStats = {...stats, toolCalls: {...stats.toolCalls}}; } // Cost rates follow pi-ai's convention: USD per 1,000,000 tokens. @@ -1472,13 +1447,15 @@ export function recordApiCall( cachedStats.totalTokens += p + c; cachedStats.estimatedCost += (p * costPerMTokPrompt + c * costPerMTokCompletion) / 1_000_000; - saveStats(); + chrome.send('recordDaoAgentApiUsage', [ + p, c, costPerMTokPrompt, costPerMTokCompletion, + ]); } export function recordToolCall(toolName: string) { cachedStats.toolCalls[toolName] = (cachedStats.toolCalls[toolName] || 0) + 1; - saveStats(); + chrome.send('recordDaoAgentToolUsage', [toolName]); } export function getAgentStats(): AgentStats { @@ -1487,7 +1464,7 @@ export function getAgentStats(): AgentStats { export function resetAgentStats() { cachedStats = defaultStats(); - saveStats(); + chrome.send('resetDaoAgentUsageStats'); } // ---- Unique ID Generator ---- diff --git a/src/dao/browser/ui/webui/resources/agent/agent_settings_native_bridge.ts b/src/dao/browser/ui/webui/resources/agent/agent_settings_native_bridge.ts new file mode 100644 index 00000000..b15ab8bf --- /dev/null +++ b/src/dao/browser/ui/webui/resources/agent/agent_settings_native_bridge.ts @@ -0,0 +1,79 @@ +// Copyright 2026 Dao Browser Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import { + addWebUIListener, + applyAgentStatsSnapshot, + callNativeArgs, + CONFIDENCE_THRESHOLD_MAP, +} from './agent_bridge.js'; +import { + applyAgentSettingsSnapshot, + initializeAgentSettingsSync, + type AgentSettingsBridge, + type AgentSettingsSnapshot, +} from './agent_settings_sync.js'; + +const nativeBridge: AgentSettingsBridge = { + async getSnapshot() { + return await callNativeArgs('getDaoAgentSettings') as AgentSettingsSnapshot; + }, + async migrateLegacy(values: Record) { + return await callNativeArgs( + 'migrateDaoAgentSettings', values) as AgentSettingsSnapshot; + }, +}; + +function applyRuntimeSettings(snapshot: AgentSettingsSnapshot): void { + applyAgentSettingsSnapshot(snapshot, localStorage); + applyAgentStatsSnapshot(snapshot.usageStats); + const values = snapshot.values; + void callNativeArgs( + 'setMemoryEnabled', values['dao_agent_memory_enabled'] === 'true'); + void callNativeArgs( + 'setDreamEnabled', values['dao_dream_enabled'] === 'true'); + void callNativeArgs( + 'setDreamDebug', values['dao_dream_debug'] === 'true'); + void callNativeArgs( + 'setProactiveEnabled', values['dao_proactive_enabled'] !== 'false'); + const threshold = values['dao_proactive_threshold'] || 'balanced'; + void callNativeArgs( + 'setConfidenceThreshold', + CONFIDENCE_THRESHOLD_MAP[threshold] ?? + CONFIDENCE_THRESHOLD_MAP['balanced']); +} + +let listenersRegistered = false; + +function registerNativeListeners(): void { + if (listenersRegistered) { + return; + } + listenersRegistered = true; + addWebUIListener('dao-agent-settings-changed', snapshot => { + if (snapshot && typeof snapshot === 'object') { + applyRuntimeSettings(snapshot as AgentSettingsSnapshot); + } + }); + addWebUIListener('dao-agent-usage-stats-changed', stats => { + if (stats && typeof stats === 'object') { + applyAgentStatsSnapshot(stats as AgentSettingsSnapshot['usageStats']); + } + }); +} + +export async function startAgentSettingsSync(): Promise { + registerNativeListeners(); + const snapshot = await initializeAgentSettingsSync( + nativeBridge, localStorage); + // initializeAgentSettingsSync already applied the cache; runtime handlers + // still need the canonical values so lifecycle-sensitive settings such as + // proactive suggestions and Dream are activated consistently. + applyRuntimeSettings(snapshot); +} + +export async function setCanonicalAgentSetting( + key: string, value: string|null): Promise { + return await callNativeArgs('setDaoAgentSetting', key, value) as boolean; +} diff --git a/src/dao/browser/ui/webui/resources/agent/agent_settings_sync.ts b/src/dao/browser/ui/webui/resources/agent/agent_settings_sync.ts new file mode 100644 index 00000000..1466e666 --- /dev/null +++ b/src/dao/browser/ui/webui/resources/agent/agent_settings_sync.ts @@ -0,0 +1,96 @@ +// Copyright 2026 Dao Browser Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import type {AgentStats} from './agent_bridge.js'; + +export interface AgentSettingsSnapshot { + migrationVersion: number; + values: Record; + usageStats: AgentStats; +} + +export interface AgentSettingsBridge { + getSnapshot(): Promise; + migrateLegacy( + values: Record): Promise; +} + +export const AGENT_SETTINGS_MIGRATION_VERSION = 2; + +const LEGACY_AGENT_STATISTICS_KEY = 'dao_agent_stats'; + +// These keys are durable user choices. Runtime state such as sessions, +// dismissed cards, and circuit-breaker timestamps deliberately stays owned by +// dao://agent and is never copied into browser settings. +export const MANAGED_AGENT_SETTING_KEYS = [ + 'dao_agent_providers', + 'dao_agent_active_provider', + 'dao_agent_api_key', + 'dao_agent_base_url', + 'dao_agent_model', + 'dao_agent_soul', + 'dao_disabled_tools', + 'dao_tool_call_show_details', + 'dao_agent_debug_mode', + 'dao_resume_last_session', + 'dao_resume_stale_hours', + 'dao_proactive_enabled', + 'dao_page_context_enabled', + 'dao_conversation_enabled', + 'dao_proactive_threshold', + 'dao_search_source', + 'dao_jina_api_key', +] as const; + +export function collectLegacyAgentSettings( + storage: Storage): Record { + const values: Record = {}; + for (const key of MANAGED_AGENT_SETTING_KEYS) { + const value = storage.getItem(key); + if (value !== null) { + values[key] = value; + } + } + const stats = storage.getItem(LEGACY_AGENT_STATISTICS_KEY); + if (stats !== null) { + values[LEGACY_AGENT_STATISTICS_KEY] = stats; + } + return values; +} + +export function applyAgentSettingsSnapshot( + snapshot: AgentSettingsSnapshot, storage: Storage): void { + for (const key of MANAGED_AGENT_SETTING_KEYS) { + const value = snapshot.values[key]; + if (typeof value === 'string') { + storage.setItem(key, value); + } else { + storage.removeItem(key); + } + } + + // Existing consumers already listen for these same-document events. Keep + // them as the compatibility boundary while native Profile prefs become the + // source of truth. + window.dispatchEvent(new Event('llm-config-changed')); + window.dispatchEvent(new Event('dao-tool-config-changed')); + window.dispatchEvent(new CustomEvent('dao-agent-debug-mode-changed', { + detail: {enabled: storage.getItem('dao_agent_debug_mode') === 'true'}, + })); + window.dispatchEvent(new CustomEvent('dao-proactive-enabled-changed', { + detail: {enabled: storage.getItem('dao_proactive_enabled') !== 'false'}, + })); +} + +export async function initializeAgentSettingsSync( + bridge: AgentSettingsBridge, + storage: Storage): Promise { + let snapshot = await bridge.getSnapshot(); + if (snapshot.migrationVersion < AGENT_SETTINGS_MIGRATION_VERSION) { + snapshot = await bridge.migrateLegacy( + collectLegacyAgentSettings(storage)); + } + applyAgentSettingsSnapshot(snapshot, storage); + return snapshot; +} From 9c54ce1a5f3865a961cdb0e4f6b9975c276330ff Mon Sep 17 00:00:00 2001 From: moonrailgun Date: Fri, 7 Aug 2026 11:43:28 +0800 Subject: [PATCH 05/13] fix(agent): harden usage synchronization --- .../agent_bridge_call_native.test.ts | 34 +++++- .../agent_settings_native_bridge.test.ts | 106 ++++++++++++++++++ .../ui/webui/resources/agent/agent_bridge.ts | 51 ++++++++- .../agent/agent_settings_native_bridge.ts | 23 +++- 4 files changed, 203 insertions(+), 11 deletions(-) create mode 100644 src/dao/browser/ui/webui/resources/agent/__tests__/agent_settings_native_bridge.test.ts diff --git a/src/dao/browser/ui/webui/resources/agent/__tests__/agent_bridge_call_native.test.ts b/src/dao/browser/ui/webui/resources/agent/__tests__/agent_bridge_call_native.test.ts index ba422776..674f0e1c 100644 --- a/src/dao/browser/ui/webui/resources/agent/__tests__/agent_bridge_call_native.test.ts +++ b/src/dao/browser/ui/webui/resources/agent/__tests__/agent_bridge_call_native.test.ts @@ -70,12 +70,44 @@ describe('callNative', () => { expect(send).toHaveBeenCalledWith( 'recordDaoAgentToolUsage', ['web_search']); resetAgentStats(); - expect(send).toHaveBeenCalledWith('resetDaoAgentUsageStats'); + const resetCall = send.mock.calls.find( + call => call[0] === 'resetDaoAgentUsageStats'); + expect(resetCall).toBeDefined(); + expect(resetCall![1]).toEqual([expect.any(String)]); + cr.webUIResponse((resetCall![1] as string[])[0]!, true, true); applyAgentStatsSnapshot(profileStats); expect(getAgentStats()).toEqual(profileStats); expect(setItem).not.toHaveBeenCalled(); }); + it('rejects invalid usage records before they diverge from native stats', () => { + const send = vi.fn(); + vi.stubGlobal('chrome', {send}); + const stats = { + apiCalls: 3, + toolCalls: {web_search: 2}, + promptTokens: 30, + completionTokens: 10, + totalTokens: 40, + estimatedCost: 0.0001, + lastReset: 1720000000000, + }; + applyAgentStatsSnapshot(stats); + + recordApiCall(-1, 0, 0, 0); + recordApiCall(Number.NaN, 0, 0, 0); + recordApiCall(0, Number.POSITIVE_INFINITY, 0, 0); + recordApiCall(0, 0, -1, 0); + recordApiCall(0, 0, Number.NaN, 0); + recordApiCall(0, 0, 0, Number.POSITIVE_INFINITY); + recordToolCall(''); + recordToolCall('a'.repeat(129)); + recordToolCall('\ud800'); + + expect(getAgentStats()).toEqual(stats); + expect(send).not.toHaveBeenCalled(); + }); + it('cancels an in-flight native tool once and ignores a late response', async () => { const send = vi.fn(); diff --git a/src/dao/browser/ui/webui/resources/agent/__tests__/agent_settings_native_bridge.test.ts b/src/dao/browser/ui/webui/resources/agent/__tests__/agent_settings_native_bridge.test.ts new file mode 100644 index 00000000..b8745d50 --- /dev/null +++ b/src/dao/browser/ui/webui/resources/agent/__tests__/agent_settings_native_bridge.test.ts @@ -0,0 +1,106 @@ +// Copyright 2026 Dao Browser Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import {beforeEach, describe, expect, it, vi} from 'vitest'; + +const mocks = vi.hoisted(() => ({ + listeners: new Map void>>(), + addWebUIListener: vi.fn(), + applyAgentSettingsSnapshot: vi.fn(), + applyAgentStatsSnapshot: vi.fn(), + callNativeArgs: vi.fn(), + initializeAgentSettingsSync: vi.fn(), +})); + +vi.mock('../agent_bridge.js', () => ({ + addWebUIListener: (event: string, callback: (value: unknown) => void) => { + mocks.addWebUIListener(event, callback); + const callbacks = mocks.listeners.get(event) || []; + callbacks.push(callback); + mocks.listeners.set(event, callbacks); + }, + applyAgentStatsSnapshot: (stats: unknown) => + mocks.applyAgentStatsSnapshot(stats), + callNativeArgs: (...args: unknown[]) => mocks.callNativeArgs(...args), + CONFIDENCE_THRESHOLD_MAP: {balanced: 0.5}, +})); + +vi.mock('../agent_settings_sync.js', () => ({ + applyAgentSettingsSnapshot: (...args: unknown[]) => + mocks.applyAgentSettingsSnapshot(...args), + initializeAgentSettingsSync: (...args: unknown[]) => + mocks.initializeAgentSettingsSync(...args), +})); + +const oldStats = { + apiCalls: 5, + toolCalls: {web_search: 2}, + promptTokens: 20, + completionTokens: 10, + totalTokens: 30, + estimatedCost: 0.0001, + lastReset: 100, +}; + +const resetStats = { + apiCalls: 0, + toolCalls: {}, + promptTokens: 0, + completionTokens: 0, + totalTokens: 0, + estimatedCost: 0, + lastReset: 200, +}; + +const oldSnapshot = { + migrationVersion: 2, + values: {}, + usageStats: oldStats, +}; + +describe('agent settings native bridge', () => { + let startAgentSettingsSync: () => Promise; + + beforeEach(async () => { + vi.resetModules(); + mocks.listeners.clear(); + mocks.addWebUIListener.mockReset(); + mocks.applyAgentSettingsSnapshot.mockReset(); + mocks.applyAgentStatsSnapshot.mockReset(); + mocks.callNativeArgs.mockReset(); + mocks.initializeAgentSettingsSync.mockReset(); + mocks.callNativeArgs.mockImplementation((method: string) => + Promise.resolve(method === 'getDaoAgentSettings' ? oldSnapshot : true)); + mocks.initializeAgentSettingsSync.mockImplementation( + async (bridge: {getSnapshot: () => Promise}) => + await bridge.getSnapshot()); + ({startAgentSettingsSync} = await import('../agent_settings_native_bridge.js')); + }); + + it('keeps a usage event that arrives before the initial snapshot', async () => { + let resolveSnapshot: (snapshot: typeof oldSnapshot) => void; + const pendingSnapshot = new Promise(resolve => { + resolveSnapshot = resolve; + }); + mocks.callNativeArgs.mockImplementation((method: string) => + method === 'getDaoAgentSettings' ? pendingSnapshot : Promise.resolve(true)); + + const starting = startAgentSettingsSync(); + const usageListener = mocks.listeners.get('dao-agent-usage-stats-changed')![0]!; + usageListener(resetStats); + resolveSnapshot!(oldSnapshot); + await starting; + + expect(mocks.applyAgentStatsSnapshot).toHaveBeenLastCalledWith(resetStats); + }); + + it('registers each native listener only once', async () => { + await startAgentSettingsSync(); + await startAgentSettingsSync(); + + expect(mocks.addWebUIListener).toHaveBeenCalledTimes(2); + expect(mocks.listeners.get('dao-agent-settings-changed')).toHaveLength(1); + expect(mocks.listeners.get('dao-agent-usage-stats-changed')).toHaveLength(1); + }); +}); diff --git a/src/dao/browser/ui/webui/resources/agent/agent_bridge.ts b/src/dao/browser/ui/webui/resources/agent/agent_bridge.ts index dd726e64..fb6cb878 100644 --- a/src/dao/browser/ui/webui/resources/agent/agent_bridge.ts +++ b/src/dao/browser/ui/webui/resources/agent/agent_bridge.ts @@ -1433,28 +1433,67 @@ export function applyAgentStatsSnapshot(stats: AgentStats): void { cachedStats = {...stats, toolCalls: {...stats.toolCalls}}; } +function isNonNegativeFiniteNumber(value: unknown): value is number { + return typeof value === 'number' && value >= 0 && Number.isFinite(value); +} + +function isValidUsageToolName(toolName: unknown): toolName is string { + if (typeof toolName !== 'string' || toolName.length === 0) { + return false; + } + try { + encodeURIComponent(toolName); + } catch (_) { + return false; + } + return new TextEncoder().encode(toolName).byteLength <= 128; +} + // Cost rates follow pi-ai's convention: USD per 1,000,000 tokens. // A token count of 1000 with costPerMTokPrompt=0.5 contributes // 1000 * 0.5 / 1_000_000 = $0.0005. export function recordApiCall( promptTokens: number, completionTokens: number, costPerMTokPrompt = 0, costPerMTokCompletion = 0) { - const p = Number(promptTokens) || 0; - const c = Number(completionTokens) || 0; + if (!isNonNegativeFiniteNumber(promptTokens) || + !isNonNegativeFiniteNumber(completionTokens) || + !isNonNegativeFiniteNumber(costPerMTokPrompt) || + !isNonNegativeFiniteNumber(costPerMTokCompletion)) { + return; + } + const p = promptTokens; + const c = completionTokens; + const estimatedCost = + (p * costPerMTokPrompt + c * costPerMTokCompletion) / 1_000_000; + if (!Number.isFinite(cachedStats.apiCalls + 1) || + !Number.isFinite(cachedStats.promptTokens + p) || + !Number.isFinite(cachedStats.completionTokens + c) || + !Number.isFinite(cachedStats.totalTokens + p + c) || + !Number.isFinite(cachedStats.estimatedCost + estimatedCost)) { + return; + } cachedStats.apiCalls++; cachedStats.promptTokens += p; cachedStats.completionTokens += c; cachedStats.totalTokens += p + c; - cachedStats.estimatedCost += - (p * costPerMTokPrompt + c * costPerMTokCompletion) / 1_000_000; + cachedStats.estimatedCost += estimatedCost; chrome.send('recordDaoAgentApiUsage', [ p, c, costPerMTokPrompt, costPerMTokCompletion, ]); } export function recordToolCall(toolName: string) { + if (!isValidUsageToolName(toolName)) { + return; + } + const currentCount = cachedStats.toolCalls[toolName] || 0; + if ((!Object.prototype.hasOwnProperty.call(cachedStats.toolCalls, toolName) && + Object.keys(cachedStats.toolCalls).length >= 128) || + !Number.isFinite(currentCount + 1)) { + return; + } cachedStats.toolCalls[toolName] = - (cachedStats.toolCalls[toolName] || 0) + 1; + currentCount + 1; chrome.send('recordDaoAgentToolUsage', [toolName]); } @@ -1464,7 +1503,7 @@ export function getAgentStats(): AgentStats { export function resetAgentStats() { cachedStats = defaultStats(); - chrome.send('resetDaoAgentUsageStats'); + void callNativeArgs('resetDaoAgentUsageStats').catch(() => {}); } // ---- Unique ID Generator ---- diff --git a/src/dao/browser/ui/webui/resources/agent/agent_settings_native_bridge.ts b/src/dao/browser/ui/webui/resources/agent/agent_settings_native_bridge.ts index b15ab8bf..c7ded538 100644 --- a/src/dao/browser/ui/webui/resources/agent/agent_settings_native_bridge.ts +++ b/src/dao/browser/ui/webui/resources/agent/agent_settings_native_bridge.ts @@ -25,9 +25,12 @@ const nativeBridge: AgentSettingsBridge = { }, }; -function applyRuntimeSettings(snapshot: AgentSettingsSnapshot): void { +function applyRuntimeSettings( + snapshot: AgentSettingsSnapshot, applyUsageStats = true): void { applyAgentSettingsSnapshot(snapshot, localStorage); - applyAgentStatsSnapshot(snapshot.usageStats); + if (applyUsageStats) { + applyUsageStatsSnapshot(snapshot.usageStats); + } const values = snapshot.values; void callNativeArgs( 'setMemoryEnabled', values['dao_agent_memory_enabled'] === 'true'); @@ -45,6 +48,13 @@ function applyRuntimeSettings(snapshot: AgentSettingsSnapshot): void { } let listenersRegistered = false; +let usageStatsGeneration = 0; + +function applyUsageStatsSnapshot(stats: AgentSettingsSnapshot['usageStats']): + void { + usageStatsGeneration++; + applyAgentStatsSnapshot(stats); +} function registerNativeListeners(): void { if (listenersRegistered) { @@ -58,19 +68,24 @@ function registerNativeListeners(): void { }); addWebUIListener('dao-agent-usage-stats-changed', stats => { if (stats && typeof stats === 'object') { - applyAgentStatsSnapshot(stats as AgentSettingsSnapshot['usageStats']); + applyUsageStatsSnapshot(stats as AgentSettingsSnapshot['usageStats']); } }); } export async function startAgentSettingsSync(): Promise { registerNativeListeners(); + const initialUsageStatsGeneration = usageStatsGeneration; const snapshot = await initializeAgentSettingsSync( nativeBridge, localStorage); // initializeAgentSettingsSync already applied the cache; runtime handlers // still need the canonical values so lifecycle-sensitive settings such as // proactive suggestions and Dream are activated consistently. - applyRuntimeSettings(snapshot); + if (usageStatsGeneration === initialUsageStatsGeneration) { + applyRuntimeSettings(snapshot); + } else { + applyRuntimeSettings(snapshot, false); + } } export async function setCanonicalAgentSetting( From 359f318e5ef5fd923c077abf982256ef747e0500 Mon Sep 17 00:00:00 2001 From: moonrailgun Date: Fri, 7 Aug 2026 11:58:57 +0800 Subject: [PATCH 06/13] feat(settings): add agent management state --- .../settings/dao_page/dao_page.ts.patch | 705 +++++++++++++++++- .../webui/settings/dao_page_test.ts.patch | 502 ++++++++++++- 2 files changed, 1202 insertions(+), 5 deletions(-) diff --git a/src/patches/chrome/browser/resources/settings/dao_page/dao_page.ts.patch b/src/patches/chrome/browser/resources/settings/dao_page/dao_page.ts.patch index 33095d73..44defa9d 100644 --- a/src/patches/chrome/browser/resources/settings/dao_page/dao_page.ts.patch +++ b/src/patches/chrome/browser/resources/settings/dao_page/dao_page.ts.patch @@ -3,7 +3,7 @@ new file mode 100644 index 0000000000..0000000001 --- /dev/null +++ b/chrome/browser/resources/settings/dao_page/dao_page.ts -@@ -0,0 +1,359 @@ +@@ -0,0 +1,1062 @@ +// Copyright 2026 Dao Browser Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. @@ -13,6 +13,8 @@ index 0000000000..0000000001 + * 'settings-dao-page' contains Dao-specific browser settings. + */ +import 'chrome://resources/cr_elements/cr_button/cr_button.js'; ++import 'chrome://resources/cr_elements/cr_dialog/cr_dialog.js'; ++import 'chrome://resources/cr_elements/cr_toggle/cr_toggle.js'; +import '../controls/settings_toggle_button.js'; +import '../settings_page/settings_section.js'; +import '../settings_shared.css.js'; @@ -24,6 +26,7 @@ index 0000000000..0000000001 +import type { + CrInputElement, +} from 'chrome://resources/cr_elements/cr_input/cr_input.js'; ++import type {CrDialogElement} from 'chrome://resources/cr_elements/cr_dialog/cr_dialog.js'; +import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js'; + +import type {SettingsToggleButtonElement} from '../controls/settings_toggle_button.js'; @@ -37,6 +40,138 @@ index 0000000000..0000000001 + 'disabled'|'ready'|'approval_requested'|'connected'; +export type DaoMcpSetupOption = 'codex'|'claude-code'|'generic-mcp'; + ++export interface DaoAgentSettingsSnapshot { ++ migrationVersion: number; ++ values: Record; ++ usageStats: DaoAgentUsageStats; ++} ++ ++export interface DaoAgentMemorySummary { ++ totalSize: number; ++ conversationCount: number; ++ episodeCount: number; ++ preferenceCount: number; ++} ++ ++export interface DaoAgentWorkspaceActivity { ++ timestamp: string; ++ operation: string; ++ path: string; ++} ++ ++export interface DaoAgentWorkspaceSummary { ++ root: string; ++ usedBytes: number; ++ capBytes: number; ++ fileCount: number; ++ fileCountCap: number; ++ recentActivity: DaoAgentWorkspaceActivity[]; ++} ++ ++export interface DaoAgentUsageStats { ++ apiCalls: number; ++ toolCalls: Record; ++ promptTokens: number; ++ completionTokens: number; ++ totalTokens: number; ++ estimatedCost: number; ++ lastReset: number; ++} ++ ++export interface DaoAgentSettingsBrowserProxy { ++ getSettings(): Promise; ++ setSetting(key: string, value: string|null): Promise; ++ getMemorySummary(): Promise; ++ clearAllMemory(): Promise; ++ getWorkspaceSummary(): Promise; ++ openWorkspace(): Promise; ++ getUsageStats(): Promise; ++ resetUsageStats(): Promise; ++} ++ ++export class DaoAgentSettingsBrowserProxyImpl implements ++ DaoAgentSettingsBrowserProxy { ++ getSettings(): Promise { ++ return sendWithPromise('getDaoAgentSettings'); ++ } ++ ++ setSetting(key: string, value: string|null): Promise { ++ return sendWithPromise('setDaoAgentSetting', key, value); ++ } ++ ++ getMemorySummary(): Promise { ++ return sendWithPromise('getDaoAgentMemorySummary'); ++ } ++ ++ clearAllMemory(): Promise { ++ return sendWithPromise('clearAllDaoAgentMemory'); ++ } ++ ++ getWorkspaceSummary(): Promise { ++ return sendWithPromise('getDaoAgentWorkspaceSummary'); ++ } ++ ++ openWorkspace(): Promise { ++ return sendWithPromise('openDaoAgentWorkspace'); ++ } ++ ++ getUsageStats(): Promise { ++ return sendWithPromise('getDaoAgentUsageStats'); ++ } ++ ++ resetUsageStats(): Promise { ++ return sendWithPromise('resetDaoAgentUsageStats'); ++ } ++ ++ static getInstance(): DaoAgentSettingsBrowserProxy { ++ return agentSettingsInstance || ++ (agentSettingsInstance = new DaoAgentSettingsBrowserProxyImpl()); ++ } ++ ++ static setInstance(proxy: DaoAgentSettingsBrowserProxy): void { ++ agentSettingsInstance = proxy; ++ } ++} ++ ++let agentSettingsInstance: DaoAgentSettingsBrowserProxy|null = null; ++ ++const AGENT_TOOL_GROUPS: Record = { ++ page: [ ++ 'get_page_info', 'get_page_html', 'get_accessibility_tree', ++ 'resolve_element_context', 'capture_screenshot', 'click_element', ++ 'agent_click', 'click_by_ref', 'move_cursor', 'highlight_element', ++ 'scroll_down', 'scroll_up', 'scroll_to_element', 'press_key_chord', ++ 'type_text', 'execute_script', ++ ], ++ tabs: ['list_tabs', 'switch_tab', 'open_tab', 'close_tab'], ++ devtools: [ ++ 'enable_network_tracking', 'get_network_requests', ++ 'clear_network_requests', 'get_network_body', ++ 'enable_console_tracking', 'get_console_messages', ++ 'clear_console_messages', 'list_page_resources', ++ 'get_resource_content', 'search_in_resources', ++ ], ++ memory: ['update_soul', 'save_memory', 'save_skill', 'activate_skill'], ++ web: ['web_search', 'fetch_url'], ++ workspace: [ ++ 'workspace_read', 'workspace_write', 'workspace_edit', 'apply_patch', ++ 'list_files', 'download', ++ ], ++}; ++ ++const AGENT_PROVIDER_DEFAULTS: Record = { ++ 'openai-compatible': { ++ baseUrl: 'https://api.openai.com/v1', ++ model: 'gpt-5', ++ }, ++ openai: {baseUrl: '', model: 'gpt-5'}, ++ anthropic: {baseUrl: '', model: 'claude-sonnet-4-5'}, ++ google: {baseUrl: '', model: 'gemini-2.5-flash'}, ++ groq: {baseUrl: '', model: 'llama-3.3-70b-versatile'}, ++ xai: {baseUrl: '', model: 'grok-4'}, ++ openrouter: {baseUrl: '', model: 'openrouter/auto'}, ++}; ++ +export interface DaoMcpStatus { + enabled: boolean; + state: DaoMcpStatusState; @@ -180,6 +315,47 @@ index 0000000000..0000000001 + type: String, + value: '24', + }, ++ agentSettingsReady_: {type: Boolean, value: false}, ++ agentActiveProvider_: {type: String, value: 'openai-compatible'}, ++ agentApiKey_: {type: String, value: ''}, ++ agentBaseUrl_: {type: String, value: 'https://api.openai.com/v1'}, ++ agentModel_: {type: String, value: 'gpt-5'}, ++ agentSoul_: {type: String, value: ''}, ++ agentShowToolDetails_: {type: Boolean, value: false}, ++ agentDebugMode_: {type: Boolean, value: false}, ++ agentResumeLastSession_: {type: Boolean, value: true}, ++ agentResumeStaleHours_: {type: String, value: '24'}, ++ agentProactiveEnabled_: {type: Boolean, value: true}, ++ agentPageContextEnabled_: {type: Boolean, value: true}, ++ agentConversationEnabled_: {type: Boolean, value: true}, ++ agentMemoryEnabled_: {type: Boolean, value: true}, ++ agentDreamEnabled_: {type: Boolean, value: true}, ++ agentDreamDebug_: {type: Boolean, value: false}, ++ agentProactiveThreshold_: {type: String, value: 'balanced'}, ++ agentSearchSource_: {type: String, value: 'auto'}, ++ agentJinaApiKey_: {type: String, value: ''}, ++ agentDreamExcludedDomains_: {type: String, value: ''}, ++ agentPageToolsEnabled_: {type: Boolean, value: true}, ++ agentTabToolsEnabled_: {type: Boolean, value: true}, ++ agentDevToolsEnabled_: {type: Boolean, value: true}, ++ agentMemoryToolsEnabled_: {type: Boolean, value: true}, ++ agentWebToolsEnabled_: {type: Boolean, value: true}, ++ agentWorkspaceToolsEnabled_: {type: Boolean, value: true}, ++ agentToolNames_: { ++ type: Array, ++ value: () => Object.values(AGENT_TOOL_GROUPS).flat(), ++ }, ++ memorySummary_: {type: Object, value: null}, ++ memoryLoading_: {type: Boolean, value: false}, ++ memoryError_: {type: Boolean, value: false}, ++ clearMemoryPending_: {type: Boolean, value: false}, ++ workspaceSummary_: {type: Object, value: null}, ++ workspaceLoading_: {type: Boolean, value: false}, ++ workspaceError_: {type: Boolean, value: false}, ++ usageStats_: {type: Object, value: null}, ++ usageLoading_: {type: Boolean, value: false}, ++ usageError_: {type: Boolean, value: false}, ++ resetUsagePending_: {type: Boolean, value: false}, + }; + } + @@ -207,6 +383,14 @@ index 0000000000..0000000001 + private daoMcpCopyRequest_ = 0; + private daoMcpBrowserProxy_: DaoMcpBrowserProxy = + DaoMcpBrowserProxyImpl.getInstance(); ++ private agentSettingsBrowserProxy_: DaoAgentSettingsBrowserProxy = ++ DaoAgentSettingsBrowserProxyImpl.getInstance(); ++ private agentSettingsValues_: Record = {}; ++ private memorySummaryRequestGeneration_ = 0; ++ private workspaceSummaryRequestGeneration_ = 0; ++ private usageStatsRequestGeneration_ = 0; ++ private clearMemoryInvoker_: HTMLElement|null = null; ++ private resetUsageInvoker_: HTMLElement|null = null; + + override connectedCallback() { + super.connectedCallback(); @@ -215,6 +399,525 @@ index 0000000000..0000000001 + (status: DaoMcpStatus) => this.updateDaoMcpStatus_(status)); + this.daoMcpBrowserProxy_.getDaoMcpStatus().then( + status => this.updateDaoMcpStatus_(status)); ++ this.addWebUiListener( ++ 'dao-agent-settings-changed', ++ (snapshot: DaoAgentSettingsSnapshot) => ++ this.updateAgentSettings_(snapshot)); ++ this.agentSettingsBrowserProxy_.getSettings().then( ++ snapshot => this.updateAgentSettings_(snapshot)); ++ this.addWebUiListener( ++ 'dao-agent-usage-stats-changed', ++ (stats: DaoAgentUsageStats) => this.updateUsageStats_(stats)); ++ void this.loadMemorySummary_(); ++ void this.loadWorkspaceSummary_(); ++ void this.loadUsageStats_(); ++ } ++ ++ declare private agentSettingsReady_: boolean; ++ declare private agentActiveProvider_: string; ++ declare private agentApiKey_: string; ++ declare private agentBaseUrl_: string; ++ declare private agentModel_: string; ++ declare private agentSoul_: string; ++ declare private agentShowToolDetails_: boolean; ++ declare private agentDebugMode_: boolean; ++ declare private agentResumeLastSession_: boolean; ++ declare private agentResumeStaleHours_: string; ++ declare private agentProactiveEnabled_: boolean; ++ declare private agentPageContextEnabled_: boolean; ++ declare private agentConversationEnabled_: boolean; ++ declare private agentMemoryEnabled_: boolean; ++ declare private agentDreamEnabled_: boolean; ++ declare private agentDreamDebug_: boolean; ++ declare private agentProactiveThreshold_: string; ++ declare private agentSearchSource_: string; ++ declare private agentJinaApiKey_: string; ++ declare private agentDreamExcludedDomains_: string; ++ declare private agentPageToolsEnabled_: boolean; ++ declare private agentTabToolsEnabled_: boolean; ++ declare private agentDevToolsEnabled_: boolean; ++ declare private agentMemoryToolsEnabled_: boolean; ++ declare private agentWebToolsEnabled_: boolean; ++ declare private agentWorkspaceToolsEnabled_: boolean; ++ declare private agentToolNames_: string[]; ++ declare private memorySummary_: DaoAgentMemorySummary|null; ++ declare private memoryLoading_: boolean; ++ declare private memoryError_: boolean; ++ declare private clearMemoryPending_: boolean; ++ declare private workspaceSummary_: DaoAgentWorkspaceSummary|null; ++ declare private workspaceLoading_: boolean; ++ declare private workspaceError_: boolean; ++ declare private usageStats_: DaoAgentUsageStats|null; ++ declare private usageLoading_: boolean; ++ declare private usageError_: boolean; ++ declare private resetUsagePending_: boolean; ++ ++ get memorySummaryForTest(): DaoAgentMemorySummary|null { ++ return this.memorySummary_; ++ } ++ ++ get memoryLoadingForTest(): boolean { ++ return this.memoryLoading_; ++ } ++ ++ get memoryErrorForTest(): boolean { ++ return this.memoryError_; ++ } ++ ++ get clearMemoryPendingForTest(): boolean { ++ return this.clearMemoryPending_; ++ } ++ ++ get workspaceSummaryForTest(): DaoAgentWorkspaceSummary|null { ++ return this.workspaceSummary_; ++ } ++ ++ get workspaceLoadingForTest(): boolean { ++ return this.workspaceLoading_; ++ } ++ ++ get workspaceErrorForTest(): boolean { ++ return this.workspaceError_; ++ } ++ ++ get usageStatsForTest(): DaoAgentUsageStats|null { ++ return this.usageStats_; ++ } ++ ++ get usageLoadingForTest(): boolean { ++ return this.usageLoading_; ++ } ++ ++ get usageErrorForTest(): boolean { ++ return this.usageError_; ++ } ++ ++ get resetUsagePendingForTest(): boolean { ++ return this.resetUsagePending_; ++ } ++ ++ private async loadMemorySummary_(): Promise { ++ const generation = ++this.memorySummaryRequestGeneration_; ++ this.memoryLoading_ = true; ++ this.memoryError_ = false; ++ try { ++ const summary = await this.agentSettingsBrowserProxy_.getMemorySummary(); ++ if (generation === this.memorySummaryRequestGeneration_) { ++ this.memorySummary_ = summary; ++ } ++ } catch { ++ if (generation === this.memorySummaryRequestGeneration_) { ++ this.memoryError_ = true; ++ } ++ } finally { ++ if (generation === this.memorySummaryRequestGeneration_) { ++ this.memoryLoading_ = false; ++ } ++ } ++ } ++ ++ private async loadWorkspaceSummary_(): Promise { ++ const generation = ++this.workspaceSummaryRequestGeneration_; ++ this.workspaceLoading_ = true; ++ this.workspaceError_ = false; ++ try { ++ const summary = ++ await this.agentSettingsBrowserProxy_.getWorkspaceSummary(); ++ if (generation === this.workspaceSummaryRequestGeneration_) { ++ this.workspaceSummary_ = summary; ++ } ++ } catch { ++ if (generation === this.workspaceSummaryRequestGeneration_) { ++ this.workspaceError_ = true; ++ } ++ } finally { ++ if (generation === this.workspaceSummaryRequestGeneration_) { ++ this.workspaceLoading_ = false; ++ } ++ } ++ } ++ ++ private async loadUsageStats_(): Promise { ++ const generation = ++this.usageStatsRequestGeneration_; ++ this.usageLoading_ = true; ++ this.usageError_ = false; ++ try { ++ const stats = await this.agentSettingsBrowserProxy_.getUsageStats(); ++ if (generation === this.usageStatsRequestGeneration_) { ++ this.usageStats_ = {...stats, toolCalls: {...stats.toolCalls}}; ++ } ++ } catch { ++ if (generation === this.usageStatsRequestGeneration_) { ++ this.usageError_ = true; ++ } ++ } finally { ++ if (generation === this.usageStatsRequestGeneration_) { ++ this.usageLoading_ = false; ++ } ++ } ++ } ++ ++ private updateUsageStats_(stats: DaoAgentUsageStats): void { ++ ++this.usageStatsRequestGeneration_; ++ this.usageStats_ = {...stats, toolCalls: {...stats.toolCalls}}; ++ this.usageLoading_ = false; ++ this.usageError_ = false; ++ } ++ ++ private onRetryMemory_(): void { ++ void this.loadMemorySummary_(); ++ } ++ ++ private onRetryWorkspace_(): void { ++ void this.loadWorkspaceSummary_(); ++ } ++ ++ private onRetryUsage_(): void { ++ void this.loadUsageStats_(); ++ } ++ ++ private getManagementDialog_(id: string): CrDialogElement { ++ return this.shadowRoot!.querySelector(`#${id}`)!; ++ } ++ ++ private closeManagementDialog_( ++ dialog: CrDialogElement, invoker: HTMLElement|null): void { ++ if (dialog.open) { ++ dialog.close(); ++ } ++ invoker?.focus(); ++ } ++ ++ private onClearMemory_(event: Event): void { ++ this.clearMemoryInvoker_ = event.currentTarget as HTMLElement; ++ this.getManagementDialog_('clearAllMemoryDialog').showModal(); ++ } ++ ++ private onCancelClearMemory_(): void { ++ if (this.clearMemoryPending_) { ++ return; ++ } ++ this.closeManagementDialog_( ++ this.getManagementDialog_('clearAllMemoryDialog'), ++ this.clearMemoryInvoker_); ++ this.clearMemoryInvoker_ = null; ++ } ++ ++ private async onConfirmClearMemory_(): Promise { ++ if (this.clearMemoryPending_) { ++ return; ++ } ++ this.clearMemoryPending_ = true; ++ try { ++ if (await this.agentSettingsBrowserProxy_.clearAllMemory()) { ++ void this.loadMemorySummary_(); ++ } else { ++ this.memoryError_ = true; ++ } ++ } catch { ++ this.memoryError_ = true; ++ } finally { ++ this.clearMemoryPending_ = false; ++ this.closeManagementDialog_( ++ this.getManagementDialog_('clearAllMemoryDialog'), ++ this.clearMemoryInvoker_); ++ this.clearMemoryInvoker_ = null; ++ } ++ } ++ ++ private async onOpenWorkspace_(): Promise { ++ try { ++ if (!await this.agentSettingsBrowserProxy_.openWorkspace()) { ++ this.workspaceError_ = true; ++ } ++ } catch { ++ this.workspaceError_ = true; ++ } ++ } ++ ++ private onResetUsage_(event: Event): void { ++ this.resetUsageInvoker_ = event.currentTarget as HTMLElement; ++ this.getManagementDialog_('resetUsageStatsDialog').showModal(); ++ } ++ ++ private onCancelResetUsage_(): void { ++ if (this.resetUsagePending_) { ++ return; ++ } ++ this.closeManagementDialog_( ++ this.getManagementDialog_('resetUsageStatsDialog'), ++ this.resetUsageInvoker_); ++ this.resetUsageInvoker_ = null; ++ } ++ ++ private async onConfirmResetUsage_(): Promise { ++ if (this.resetUsagePending_) { ++ return; ++ } ++ this.resetUsagePending_ = true; ++ try { ++ if (await this.agentSettingsBrowserProxy_.resetUsageStats()) { ++ void this.loadUsageStats_(); ++ } else { ++ this.usageError_ = true; ++ } ++ } catch { ++ this.usageError_ = true; ++ } finally { ++ this.resetUsagePending_ = false; ++ this.closeManagementDialog_( ++ this.getManagementDialog_('resetUsageStatsDialog'), ++ this.resetUsageInvoker_); ++ this.resetUsageInvoker_ = null; ++ } ++ } ++ ++ private formatBytes_(bytes: number): string { ++ const units = ['byte', 'kilobyte', 'megabyte', 'gigabyte', 'terabyte']; ++ const safeBytes = Number.isFinite(bytes) ? Math.max(0, bytes) : 0; ++ const unitIndex = safeBytes === 0 ? 0 : Math.min( ++ Math.floor(Math.log(safeBytes) / Math.log(1024)), units.length - 1); ++ const value = safeBytes / 1024 ** unitIndex; ++ return new Intl.NumberFormat(undefined, { ++ style: 'unit', ++ unit: units[unitIndex], ++ unitDisplay: 'short', ++ maximumFractionDigits: unitIndex === 0 ? 0 : 1, ++ }).format(value); ++ } ++ ++ private formatInteger_(value: number): string { ++ return new Intl.NumberFormat(undefined, { ++ maximumFractionDigits: 0, ++ }).format(value); ++ } ++ ++ private formatCost_(value: number): string { ++ return new Intl.NumberFormat(undefined, { ++ style: 'currency', ++ currency: 'USD', ++ minimumFractionDigits: 2, ++ maximumFractionDigits: 6, ++ }).format(value); ++ } ++ ++ private formatTimestamp_(value: number|string): string { ++ return new Intl.DateTimeFormat(undefined, { ++ dateStyle: 'medium', ++ timeStyle: 'short', ++ }).format(new Date(value)); ++ } ++ ++ private getUsageToolEntries_(stats: DaoAgentUsageStats|null): Array<{ ++ name: string, ++ count: number, ++ }> { ++ return stats ? Object.entries(stats.toolCalls) ++ .map(([name, count]) => ({name, count})) ++ .sort((a, b) => b.count - a.count) : ++ []; ++ } ++ ++ private readAgentBoolean_(key: string, fallback: boolean): boolean { ++ const value = this.agentSettingsValues_[key]; ++ return value === undefined ? fallback : value === 'true'; ++ } ++ ++ private getAgentProviders_(): Record { ++ try { ++ const parsed = JSON.parse( ++ this.agentSettingsValues_['dao_agent_providers'] || '{}'); ++ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? ++ parsed : {}; ++ } catch { ++ return {}; ++ } ++ } ++ ++ private updateActiveAgentProviderFields_() { ++ const provider = this.getAgentProviders_()[this.agentActiveProvider_]; ++ const defaults = AGENT_PROVIDER_DEFAULTS[this.agentActiveProvider_] || ++ AGENT_PROVIDER_DEFAULTS['openai-compatible']; ++ this.agentApiKey_ = provider?.apiKey || ''; ++ this.agentBaseUrl_ = provider?.baseUrl || defaults.baseUrl; ++ this.agentModel_ = provider?.model || defaults.model; ++ } ++ ++ private updateAgentSettings_(snapshot: DaoAgentSettingsSnapshot) { ++ this.agentSettingsValues_ = {...snapshot.values}; ++ this.agentActiveProvider_ = ++ snapshot.values['dao_agent_active_provider'] || 'openai-compatible'; ++ this.updateActiveAgentProviderFields_(); ++ this.agentSoul_ = snapshot.values['dao_agent_soul'] || ''; ++ this.agentShowToolDetails_ = ++ this.readAgentBoolean_('dao_tool_call_show_details', false); ++ this.agentDebugMode_ = ++ this.readAgentBoolean_('dao_agent_debug_mode', false); ++ this.agentResumeLastSession_ = ++ this.readAgentBoolean_('dao_resume_last_session', true); ++ this.agentResumeStaleHours_ = ++ snapshot.values['dao_resume_stale_hours'] || '24'; ++ this.agentProactiveEnabled_ = ++ this.readAgentBoolean_('dao_proactive_enabled', true); ++ this.agentPageContextEnabled_ = ++ this.readAgentBoolean_('dao_page_context_enabled', true); ++ this.agentConversationEnabled_ = ++ this.readAgentBoolean_('dao_conversation_enabled', true); ++ this.agentMemoryEnabled_ = ++ this.readAgentBoolean_('dao_agent_memory_enabled', true); ++ this.agentDreamEnabled_ = ++ this.readAgentBoolean_('dao_dream_enabled', true); ++ this.agentDreamDebug_ = ++ this.readAgentBoolean_('dao_dream_debug', false); ++ this.agentProactiveThreshold_ = ++ snapshot.values['dao_proactive_threshold'] || 'balanced'; ++ this.agentSearchSource_ = ++ snapshot.values['dao_search_source'] || 'auto'; ++ this.agentJinaApiKey_ = snapshot.values['dao_jina_api_key'] || ''; ++ let disabledTools = new Set(); ++ try { ++ const parsed = JSON.parse(snapshot.values['dao_disabled_tools'] || '[]'); ++ if (Array.isArray(parsed)) { ++ disabledTools = new Set( ++ parsed.filter((value): value is string => ++ typeof value === 'string')); ++ } ++ } catch { ++ disabledTools = new Set(); ++ } ++ const groupEnabled = (group: string) => ++ AGENT_TOOL_GROUPS[group].some(tool => !disabledTools.has(tool)); ++ this.agentPageToolsEnabled_ = groupEnabled('page'); ++ this.agentTabToolsEnabled_ = groupEnabled('tabs'); ++ this.agentDevToolsEnabled_ = groupEnabled('devtools'); ++ this.agentMemoryToolsEnabled_ = groupEnabled('memory'); ++ this.agentWebToolsEnabled_ = groupEnabled('web'); ++ this.agentWorkspaceToolsEnabled_ = groupEnabled('workspace'); ++ try { ++ const domains = JSON.parse( ++ snapshot.values['dao_dream_excluded_domains'] || '[]'); ++ this.agentDreamExcludedDomains_ = Array.isArray(domains) ? ++ domains.join('\n') : ''; ++ } catch { ++ this.agentDreamExcludedDomains_ = ''; ++ } ++ this.agentSettingsReady_ = true; ++ } ++ ++ private async setAgentSetting_(key: string, value: string|null) { ++ const accepted = await this.agentSettingsBrowserProxy_.setSetting( ++ key, value); ++ if (accepted && value !== null) { ++ this.agentSettingsValues_ = {...this.agentSettingsValues_, [key]: value}; ++ } ++ } ++ ++ private onAgentBooleanSettingChange_(event: Event) { ++ const target = event.currentTarget as HTMLElement & {checked: boolean}; ++ const key = target.dataset['setting']; ++ if (key) { ++ void this.setAgentSetting_(key, String(target.checked)); ++ } ++ } ++ ++ private onAgentTextSettingChange_(event: Event) { ++ const target = event.currentTarget as HTMLInputElement; ++ const key = target.dataset['setting']; ++ if (key) { ++ void this.setAgentSetting_(key, target.value); ++ } ++ } ++ ++ private onAgentProviderChange_(event: Event) { ++ this.agentActiveProvider_ = ++ (event.currentTarget as HTMLSelectElement).value; ++ void this.setAgentSetting_( ++ 'dao_agent_active_provider', this.agentActiveProvider_); ++ this.updateActiveAgentProviderFields_(); ++ } ++ ++ private onAgentProviderFieldChange_() { ++ const providers = this.getAgentProviders_(); ++ providers[this.agentActiveProvider_] = { ++ apiKey: this.agentApiKey_, ++ baseUrl: this.agentBaseUrl_, ++ model: this.agentModel_, ++ }; ++ void this.setAgentSetting_( ++ 'dao_agent_providers', JSON.stringify(providers)); ++ } ++ ++ private onAgentDreamDomainsChange_() { ++ const domains = this.agentDreamExcludedDomains_.split('\n') ++ .map(domain => domain.trim()) ++ .filter(Boolean); ++ void this.setAgentSetting_( ++ 'dao_dream_excluded_domains', JSON.stringify(domains)); ++ } ++ ++ private onAgentToolGroupChange_(event: Event) { ++ const target = event.currentTarget as HTMLElement & {checked: boolean}; ++ const group = target.dataset['toolGroup']; ++ const tools = group ? AGENT_TOOL_GROUPS[group] : undefined; ++ if (!tools) { ++ return; ++ } ++ let disabled = new Set(); ++ try { ++ const parsed = JSON.parse( ++ this.agentSettingsValues_['dao_disabled_tools'] || '[]'); ++ if (Array.isArray(parsed)) { ++ disabled = new Set(parsed.filter(value => typeof value === 'string')); ++ } ++ } catch { ++ disabled = new Set(); ++ } ++ for (const tool of tools) { ++ if (target.checked) { ++ disabled.delete(tool); ++ } else { ++ disabled.add(tool); ++ } ++ } ++ void this.setAgentSetting_( ++ 'dao_disabled_tools', JSON.stringify([...disabled])); ++ } ++ ++ private isAgentToolEnabled_( ++ tool: string, values: Record): boolean { ++ try { ++ const parsed = JSON.parse(values['dao_disabled_tools'] || '[]'); ++ return !Array.isArray(parsed) || !parsed.includes(tool); ++ } catch { ++ return true; ++ } ++ } ++ ++ private onAgentToolChange_(event: Event) { ++ const target = event.currentTarget as HTMLElement & {checked: boolean}; ++ const tool = target.dataset['toolName']; ++ if (!tool) { ++ return; ++ } ++ let disabled = new Set(); ++ try { ++ const parsed = JSON.parse( ++ this.agentSettingsValues_['dao_disabled_tools'] || '[]'); ++ if (Array.isArray(parsed)) { ++ disabled = new Set(parsed.filter(value => typeof value === 'string')); ++ } ++ } catch { ++ disabled = new Set(); ++ } ++ if (target.checked) { ++ disabled.delete(tool); ++ } else { ++ disabled.add(tool); ++ } ++ void this.setAgentSetting_( ++ 'dao_disabled_tools', JSON.stringify([...disabled])); + } + + declare private staleTabExpirationHoursInvalid_: boolean; diff --git a/src/patches/chrome/test/data/webui/settings/dao_page_test.ts.patch b/src/patches/chrome/test/data/webui/settings/dao_page_test.ts.patch index 326734ee..249cf688 100644 --- a/src/patches/chrome/test/data/webui/settings/dao_page_test.ts.patch +++ b/src/patches/chrome/test/data/webui/settings/dao_page_test.ts.patch @@ -3,7 +3,7 @@ new file mode 100644 index 0000000000..0000000001 --- /dev/null +++ b/chrome/test/data/webui/settings/dao_page_test.ts -@@ -0,0 +1,515 @@ +@@ -0,0 +1,1009 @@ +// Copyright 2026 Dao Browser Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. @@ -13,13 +13,202 @@ index 0000000000..0000000001 +import type {CrInputElement} from 'chrome://resources/cr_elements/cr_input/cr_input.js'; +import {webUIListenerCallback} from 'chrome://resources/js/cr.js'; +import {flush} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js'; -+import type {DaoMcpBrowserProxy, DaoMcpStatus, SettingsDaoPageElement, SettingsToggleButtonElement} from 'chrome://settings/settings.js'; -+import {DaoMcpBrowserProxyImpl, loadTimeData, Router, routes} from 'chrome://settings/settings.js'; -+import {assertEquals, assertFalse, assertNull, assertTrue} from 'chrome://webui-test/chai_assert.js'; ++import type {DaoAgentSettingsBrowserProxy, DaoAgentSettingsSnapshot, DaoMcpBrowserProxy, DaoMcpStatus, SettingsDaoPageElement, SettingsToggleButtonElement} from 'chrome://settings/settings.js'; ++import {DaoAgentSettingsBrowserProxyImpl, DaoMcpBrowserProxyImpl, loadTimeData, Router, routes} from 'chrome://settings/settings.js'; ++import {assertDeepEquals, assertEquals, assertFalse, assertNull, assertTrue} from 'chrome://webui-test/chai_assert.js'; +import {flushTasks} from 'chrome://webui-test/polymer_test_util.js'; +import {TestBrowserProxy} from 'chrome://webui-test/test_browser_proxy.js'; + +type DaoMcpSetupOption = 'codex'|'claude-code'|'generic-mcp'; ++type DaoAgentMemorySummary = ++ Awaited>; ++type DaoAgentWorkspaceSummary = ++ Awaited>; ++type DaoAgentUsageStats = ++ Awaited>; ++ ++interface Deferred { ++ resolve: (value: T) => void; ++ reject: (reason?: unknown) => void; ++} ++ ++const memorySummary: DaoAgentMemorySummary = { ++ totalSize: 1536, ++ conversationCount: 12, ++ episodeCount: 4, ++ preferenceCount: 3, ++}; ++ ++const workspaceSummary: DaoAgentWorkspaceSummary = { ++ root: '/Users/test/Dao Agent Workspace', ++ usedBytes: 4096, ++ capBytes: 1048576, ++ fileCount: 7, ++ fileCountCap: 100, ++ recentActivity: [{ ++ timestamp: '2026-08-07T01:02:03.000Z', ++ operation: 'read', ++ path: 'notes/example.md', ++ }], ++}; ++ ++const usageStats: DaoAgentUsageStats = { ++ apiCalls: 9, ++ toolCalls: {workspace_read: 5}, ++ promptTokens: 1200, ++ completionTokens: 300, ++ totalTokens: 1500, ++ estimatedCost: 0.000123, ++ lastReset: 1786064523000, ++}; ++ ++class TestDaoAgentSettingsBrowserProxy extends TestBrowserProxy implements ++ DaoAgentSettingsBrowserProxy { ++ private snapshot_: DaoAgentSettingsSnapshot = { ++ migrationVersion: 2, ++ values: { ++ dao_agent_active_provider: 'anthropic', ++ dao_agent_providers: JSON.stringify({ ++ anthropic: {apiKey: 'secret', baseUrl: '', model: 'claude-test'}, ++ }), ++ dao_agent_memory_enabled: 'true', ++ dao_dream_enabled: 'false', ++ }, ++ usageStats: { ++ apiCalls: 0, ++ toolCalls: {}, ++ promptTokens: 0, ++ completionTokens: 0, ++ totalTokens: 0, ++ estimatedCost: 0, ++ lastReset: 0, ++ }, ++ }; ++ private memorySummaryRequests_: Array> = []; ++ private workspaceSummaryRequests_: ++ Array> = []; ++ private usageStatsRequests_: Array> = []; ++ private clearMemoryRequests_: Array> = []; ++ private resetUsageRequests_: Array> = []; ++ ++ constructor() { ++ super([ ++ 'getSettings', ++ 'setSetting', ++ 'getMemorySummary', ++ 'clearAllMemory', ++ 'getWorkspaceSummary', ++ 'openWorkspace', ++ 'getUsageStats', ++ 'resetUsageStats', ++ ]); ++ } ++ ++ getSettings(): Promise { ++ this.methodCalled('getSettings'); ++ return Promise.resolve(this.snapshot_); ++ } ++ ++ setSetting(key: string, value: string|null): Promise { ++ this.methodCalled('setSetting', key, value); ++ return Promise.resolve(true); ++ } ++ ++ getMemorySummary(): Promise { ++ this.methodCalled('getMemorySummary'); ++ return new Promise((resolve, reject) => { ++ this.memorySummaryRequests_.push({resolve, reject}); ++ }); ++ } ++ ++ clearAllMemory(): Promise { ++ this.methodCalled('clearAllMemory'); ++ return new Promise((resolve, reject) => { ++ this.clearMemoryRequests_.push({resolve, reject}); ++ }); ++ } ++ ++ getWorkspaceSummary(): Promise { ++ this.methodCalled('getWorkspaceSummary'); ++ return new Promise((resolve, reject) => { ++ this.workspaceSummaryRequests_.push({resolve, reject}); ++ }); ++ } ++ ++ openWorkspace(): Promise { ++ this.methodCalled('openWorkspace'); ++ return Promise.resolve(true); ++ } ++ ++ getUsageStats(): Promise { ++ this.methodCalled('getUsageStats'); ++ return new Promise((resolve, reject) => { ++ this.usageStatsRequests_.push({resolve, reject}); ++ }); ++ } ++ ++ resetUsageStats(): Promise { ++ this.methodCalled('resetUsageStats'); ++ return new Promise((resolve, reject) => { ++ this.resetUsageRequests_.push({resolve, reject}); ++ }); ++ } ++ ++ resolveMemorySummary(summary: DaoAgentMemorySummary, request = 0): void { ++ const deferred = this.memorySummaryRequests_[request]; ++ assertTrue(!!deferred); ++ this.memorySummaryRequests_.splice(request, 1); ++ deferred.resolve(summary); ++ } ++ ++ rejectMemorySummary(request = 0): void { ++ const deferred = this.memorySummaryRequests_[request]; ++ assertTrue(!!deferred); ++ this.memorySummaryRequests_.splice(request, 1); ++ deferred.reject(new Error('memory summary failed')); ++ } ++ ++ resolveWorkspaceSummary( ++ summary: DaoAgentWorkspaceSummary, request = 0): void { ++ const deferred = this.workspaceSummaryRequests_[request]; ++ assertTrue(!!deferred); ++ this.workspaceSummaryRequests_.splice(request, 1); ++ deferred.resolve(summary); ++ } ++ ++ rejectWorkspaceSummary(request = 0): void { ++ const deferred = this.workspaceSummaryRequests_[request]; ++ assertTrue(!!deferred); ++ this.workspaceSummaryRequests_.splice(request, 1); ++ deferred.reject(new Error('workspace summary failed')); ++ } ++ ++ resolveUsageStats(stats: DaoAgentUsageStats, request = 0): void { ++ const deferred = this.usageStatsRequests_[request]; ++ assertTrue(!!deferred); ++ this.usageStatsRequests_.splice(request, 1); ++ deferred.resolve(stats); ++ } ++ ++ rejectUsageStats(request = 0): void { ++ const deferred = this.usageStatsRequests_[request]; ++ assertTrue(!!deferred); ++ this.usageStatsRequests_.splice(request, 1); ++ deferred.reject(new Error('usage stats failed')); ++ } ++ ++ resolveClearAllMemory(success: boolean): void { ++ const deferred = this.clearMemoryRequests_.shift(); ++ assertTrue(!!deferred); ++ deferred.resolve(success); ++ } ++ ++ resolveResetUsageStats(success: boolean): void { ++ const deferred = this.resetUsageRequests_.shift(); ++ assertTrue(!!deferred); ++ deferred.resolve(success); ++ } ++} + +class TestDaoMcpBrowserProxy extends TestBrowserProxy implements + DaoMcpBrowserProxy { @@ -88,10 +277,13 @@ index 0000000000..0000000001 +suite('DaoPage', function() { + let page: SettingsDaoPageElement; + let mcpBrowserProxy: TestDaoMcpBrowserProxy; ++ let agentSettingsBrowserProxy: TestDaoAgentSettingsBrowserProxy; + + setup(async function() { + mcpBrowserProxy = new TestDaoMcpBrowserProxy(); + DaoMcpBrowserProxyImpl.setInstance(mcpBrowserProxy); ++ agentSettingsBrowserProxy = new TestDaoAgentSettingsBrowserProxy(); ++ DaoAgentSettingsBrowserProxyImpl.setInstance(agentSettingsBrowserProxy); + document.body.innerHTML = window.trustedTypes!.emptyHTML; + page = document.createElement('settings-dao-page'); + page.prefs = { @@ -125,6 +317,7 @@ index 0000000000..0000000001 + }; + document.body.appendChild(page); + await mcpBrowserProxy.whenCalled('getDaoMcpStatus'); ++ await agentSettingsBrowserProxy.whenCalled('getSettings'); + await flushTasks(); + flush(); + }); @@ -146,6 +339,307 @@ index 0000000000..0000000001 + flush(); + } + ++ async function resolveAllManagementSummaries(): Promise { ++ agentSettingsBrowserProxy.resolveMemorySummary(memorySummary); ++ agentSettingsBrowserProxy.resolveWorkspaceSummary(workspaceSummary); ++ agentSettingsBrowserProxy.resolveUsageStats(usageStats); ++ await flushTasks(); ++ flush(); ++ } ++ ++ test('agentManagementSummariesLoadIndependently', async function() { ++ assertEquals( ++ 1, agentSettingsBrowserProxy.getCallCount('getMemorySummary')); ++ assertEquals( ++ 1, agentSettingsBrowserProxy.getCallCount('getWorkspaceSummary')); ++ assertEquals(1, agentSettingsBrowserProxy.getCallCount('getUsageStats')); ++ assertTrue(page.memoryLoadingForTest); ++ assertTrue(page.workspaceLoadingForTest); ++ assertTrue(page.usageLoadingForTest); ++ ++ agentSettingsBrowserProxy.resolveMemorySummary(memorySummary); ++ await flushTasks(); ++ ++ assertFalse(page.memoryLoadingForTest); ++ assertTrue(page.workspaceLoadingForTest); ++ assertTrue(page.usageLoadingForTest); ++ assertDeepEquals(memorySummary, page.memorySummaryForTest); ++ }); ++ ++ test('memoryFailureDoesNotReplaceOtherManagementCards', async function() { ++ agentSettingsBrowserProxy.rejectMemorySummary(); ++ agentSettingsBrowserProxy.resolveWorkspaceSummary(workspaceSummary); ++ agentSettingsBrowserProxy.resolveUsageStats(usageStats); ++ await flushTasks(); ++ flush(); ++ ++ assertTrue(page.memoryErrorForTest); ++ assertFalse(page.workspaceErrorForTest); ++ assertFalse(page.usageErrorForTest); ++ assertDeepEquals(workspaceSummary, page.workspaceSummaryForTest); ++ assertDeepEquals(usageStats, page.usageStatsForTest); ++ assertTrue(!!page.shadowRoot!.querySelector('#agentMemoryRetry')); ++ assertNull(page.shadowRoot!.querySelector('#agentWorkspaceRetry')); ++ assertNull(page.shadowRoot!.querySelector('#agentUsageRetry')); ++ }); ++ ++ test('workspaceFailureDoesNotReplaceOtherManagementCards', ++ async function() { ++ agentSettingsBrowserProxy.resolveMemorySummary(memorySummary); ++ agentSettingsBrowserProxy.rejectWorkspaceSummary(); ++ agentSettingsBrowserProxy.resolveUsageStats(usageStats); ++ await flushTasks(); ++ flush(); ++ ++ assertFalse(page.memoryErrorForTest); ++ assertTrue(page.workspaceErrorForTest); ++ assertFalse(page.usageErrorForTest); ++ assertDeepEquals(memorySummary, page.memorySummaryForTest); ++ assertDeepEquals(usageStats, page.usageStatsForTest); ++ assertNull(page.shadowRoot!.querySelector('#agentMemoryRetry')); ++ assertTrue(!!page.shadowRoot!.querySelector('#agentWorkspaceRetry')); ++ assertNull(page.shadowRoot!.querySelector('#agentUsageRetry')); ++ }); ++ ++ test('usageFailureDoesNotReplaceOtherManagementCards', async function() { ++ agentSettingsBrowserProxy.resolveMemorySummary(memorySummary); ++ agentSettingsBrowserProxy.resolveWorkspaceSummary(workspaceSummary); ++ agentSettingsBrowserProxy.rejectUsageStats(); ++ await flushTasks(); ++ flush(); ++ ++ assertFalse(page.memoryErrorForTest); ++ assertFalse(page.workspaceErrorForTest); ++ assertTrue(page.usageErrorForTest); ++ assertDeepEquals(memorySummary, page.memorySummaryForTest); ++ assertDeepEquals(workspaceSummary, page.workspaceSummaryForTest); ++ assertNull(page.shadowRoot!.querySelector('#agentMemoryRetry')); ++ assertNull(page.shadowRoot!.querySelector('#agentWorkspaceRetry')); ++ assertTrue(!!page.shadowRoot!.querySelector('#agentUsageRetry')); ++ }); ++ ++ test('workspaceRetryFailurePreservesLastGoodData', async function() { ++ agentSettingsBrowserProxy.resolveMemorySummary(memorySummary); ++ agentSettingsBrowserProxy.rejectWorkspaceSummary(); ++ agentSettingsBrowserProxy.resolveUsageStats(usageStats); ++ await flushTasks(); ++ flush(); ++ ++ const retry = page.shadowRoot!.querySelector( ++ '#agentWorkspaceRetry')!; ++ retry.click(); ++ agentSettingsBrowserProxy.resolveWorkspaceSummary(workspaceSummary); ++ await flushTasks(); ++ flush(); ++ assertDeepEquals(workspaceSummary, page.workspaceSummaryForTest); ++ ++ retry.click(); ++ agentSettingsBrowserProxy.rejectWorkspaceSummary(); ++ await flushTasks(); ++ assertTrue(page.workspaceErrorForTest); ++ assertDeepEquals(workspaceSummary, page.workspaceSummaryForTest); ++ }); ++ ++ test('usageEventWinsOverStaleInitialResponse', async function() { ++ const newerStats = {...usageStats, apiCalls: 10}; ++ webUIListenerCallback('dao-agent-usage-stats-changed', newerStats); ++ agentSettingsBrowserProxy.resolveUsageStats(usageStats); ++ await flushTasks(); ++ ++ assertDeepEquals(newerStats, page.usageStatsForTest); ++ assertFalse(page.usageLoadingForTest); ++ }); ++ ++ test('cancelClearMemoryDoesNotCallNativeAndRestoresFocus', ++ async function() { ++ await resolveAllManagementSummaries(); ++ const clearButton = page.shadowRoot!.querySelector( ++ '#clearAllMemoryButton')!; ++ const clearDialog = page.shadowRoot!.querySelector('#clearAllMemoryDialog')!; ++ ++ clearButton.click(); ++ assertTrue(clearDialog.open); ++ clearDialog.querySelector('#clearAllMemoryCancel')! ++ .click(); ++ ++ assertEquals( ++ 0, agentSettingsBrowserProxy.getCallCount('clearAllMemory')); ++ assertEquals(clearButton, page.shadowRoot!.activeElement); ++ }); ++ ++ test('confirmClearMemoryReloadsOnlyMemoryAndPreservesLastGoodData', ++ async function() { ++ await resolveAllManagementSummaries(); ++ const clearButton = page.shadowRoot!.querySelector( ++ '#clearAllMemoryButton')!; ++ const clearDialog = page.shadowRoot!.querySelector('#clearAllMemoryDialog')!; ++ const confirm = clearDialog.querySelector('#clearAllMemoryConfirm')!; ++ ++ clearButton.click(); ++ confirm.click(); ++ await flushTasks(); ++ assertEquals( ++ 1, agentSettingsBrowserProxy.getCallCount('clearAllMemory')); ++ assertTrue(page.clearMemoryPendingForTest); ++ assertTrue(confirm.disabled); ++ ++ agentSettingsBrowserProxy.resolveClearAllMemory(true); ++ await flushTasks(); ++ assertFalse(clearDialog.open); ++ assertEquals(clearButton, page.shadowRoot!.activeElement); ++ assertEquals( ++ 2, agentSettingsBrowserProxy.getCallCount('getMemorySummary')); ++ assertEquals( ++ 1, agentSettingsBrowserProxy.getCallCount('getWorkspaceSummary')); ++ assertEquals( ++ 1, agentSettingsBrowserProxy.getCallCount('getUsageStats')); ++ ++ agentSettingsBrowserProxy.rejectMemorySummary(); ++ await flushTasks(); ++ assertTrue(page.memoryErrorForTest); ++ assertDeepEquals(memorySummary, page.memorySummaryForTest); ++ }); ++ ++ test('openWorkspaceCallsOnlyWorkspaceAction', async function() { ++ await resolveAllManagementSummaries(); ++ page.shadowRoot! ++ .querySelector('#openAgentWorkspaceButton')!.click(); ++ await agentSettingsBrowserProxy.whenCalled('openWorkspace'); ++ ++ assertEquals(1, agentSettingsBrowserProxy.getCallCount('openWorkspace')); ++ assertEquals( ++ 1, agentSettingsBrowserProxy.getCallCount('getWorkspaceSummary')); ++ }); ++ ++ test('cancelResetUsageDoesNotCallNativeAndRestoresFocus', ++ async function() { ++ await resolveAllManagementSummaries(); ++ const resetButton = page.shadowRoot!.querySelector( ++ '#resetUsageStatsButton')!; ++ const resetDialog = page.shadowRoot!.querySelector('#resetUsageStatsDialog')!; ++ ++ resetButton.click(); ++ assertTrue(resetDialog.open); ++ resetDialog.querySelector('#resetUsageStatsCancel')! ++ .click(); ++ ++ assertEquals( ++ 0, agentSettingsBrowserProxy.getCallCount('resetUsageStats')); ++ assertEquals(resetButton, page.shadowRoot!.activeElement); ++ }); ++ ++ test('confirmResetUsageReloadsOnlyUsageAndDisablesWhilePending', ++ async function() { ++ await resolveAllManagementSummaries(); ++ const resetButton = page.shadowRoot!.querySelector( ++ '#resetUsageStatsButton')!; ++ const resetDialog = page.shadowRoot!.querySelector('#resetUsageStatsDialog')!; ++ const confirm = resetDialog.querySelector('#resetUsageStatsConfirm')!; ++ ++ resetButton.click(); ++ confirm.click(); ++ await flushTasks(); ++ assertEquals( ++ 1, agentSettingsBrowserProxy.getCallCount('resetUsageStats')); ++ assertTrue(page.resetUsagePendingForTest); ++ assertTrue(confirm.disabled); ++ ++ agentSettingsBrowserProxy.resolveResetUsageStats(true); ++ await flushTasks(); ++ assertFalse(resetDialog.open); ++ assertEquals(resetButton, page.shadowRoot!.activeElement); ++ assertEquals( ++ 1, agentSettingsBrowserProxy.getCallCount('getMemorySummary')); ++ assertEquals( ++ 1, agentSettingsBrowserProxy.getCallCount('getWorkspaceSummary')); ++ assertEquals(2, agentSettingsBrowserProxy.getCallCount('getUsageStats')); ++ agentSettingsBrowserProxy.rejectUsageStats(); ++ await flushTasks(); ++ assertTrue(page.usageErrorForTest); ++ assertDeepEquals(usageStats, page.usageStatsForTest); ++ }); ++ ++ test('newerMemoryReloadWinsOverStaleResponse', async function() { ++ await resolveAllManagementSummaries(); ++ const clearButton = page.shadowRoot!.querySelector( ++ '#clearAllMemoryButton')!; ++ const clearDialog = page.shadowRoot!.querySelector( ++ '#clearAllMemoryDialog')!; ++ ++ clearButton.click(); ++ clearDialog.querySelector('#clearAllMemoryConfirm')!.click(); ++ agentSettingsBrowserProxy.resolveClearAllMemory(true); ++ await flushTasks(); ++ clearButton.click(); ++ clearDialog.querySelector('#clearAllMemoryConfirm')!.click(); ++ agentSettingsBrowserProxy.resolveClearAllMemory(true); ++ await flushTasks(); ++ ++ const newestSummary = {...memorySummary, conversationCount: 1}; ++ agentSettingsBrowserProxy.resolveMemorySummary(newestSummary, 1); ++ await flushTasks(); ++ agentSettingsBrowserProxy.resolveMemorySummary(memorySummary); ++ await flushTasks(); ++ ++ assertDeepEquals(newestSummary, page.memorySummaryForTest); ++ assertFalse(page.memoryLoadingForTest); ++ }); ++ ++ test('agentSettingsLoadFromProfileSnapshot', function() { ++ const provider = page.shadowRoot!.querySelector( ++ '#daoAgentProvider'); ++ const model = ++ page.shadowRoot!.querySelector('#daoAgentModel'); ++ const memory = page.shadowRoot!.querySelector('[data-setting="dao_agent_memory_enabled"]'); ++ const dream = page.shadowRoot!.querySelector('[data-setting="dao_dream_enabled"]'); ++ assertTrue(!!provider); ++ assertTrue(!!model); ++ assertTrue(!!memory); ++ assertTrue(!!dream); ++ assertEquals('anthropic', provider.value); ++ assertEquals('claude-test', model.value); ++ assertTrue(memory.checked); ++ assertFalse(dream.checked); ++ }); ++ ++ test('agentSearchOptionsUseRuntimeOverrideValues', function() { ++ const search = page.shadowRoot!.querySelector( ++ '#daoAgentSearchSource'); ++ assertTrue(!!search); ++ assertEquals( ++ 'auto,provider,duckduckgo', ++ [...search.options].map(option => option.value).join(',')); ++ }); ++ ++ test('agentSettingChangesWriteThroughProfileProxy', async function() { ++ const memory = page.shadowRoot!.querySelector('[data-setting="dao_agent_memory_enabled"]'); ++ assertTrue(!!memory); ++ memory.checked = false; ++ memory.dispatchEvent(new Event('change')); ++ ++ const args = await agentSettingsBrowserProxy.whenCalled('setSetting'); ++ assertEquals('dao_agent_memory_enabled', args[0]); ++ assertEquals('false', args[1]); ++ }); ++ + test('mcpMasterSwitchDefaultsOff', function() { + const toggle = getMcpToggle(); + assertEquals('dao.mcp_server_enabled', toggle.pref!.key); From 71f66bd3ae34adec1ce2203a01ab438e9da28a94 Mon Sep 17 00:00:00 2001 From: moonrailgun Date: Fri, 7 Aug 2026 12:09:57 +0800 Subject: [PATCH 07/13] fix(settings): validate agent management data --- .../settings/dao_page/dao_page.ts.patch | 55 ++++++++++++-- .../webui/settings/dao_page_test.ts.patch | 74 +++++++++++++------ 2 files changed, 100 insertions(+), 29 deletions(-) diff --git a/src/patches/chrome/browser/resources/settings/dao_page/dao_page.ts.patch b/src/patches/chrome/browser/resources/settings/dao_page/dao_page.ts.patch index 44defa9d..8dcd3d6d 100644 --- a/src/patches/chrome/browser/resources/settings/dao_page/dao_page.ts.patch +++ b/src/patches/chrome/browser/resources/settings/dao_page/dao_page.ts.patch @@ -3,7 +3,7 @@ new file mode 100644 index 0000000000..0000000001 --- /dev/null +++ b/chrome/browser/resources/settings/dao_page/dao_page.ts -@@ -0,0 +1,1062 @@ +@@ -0,0 +1,1107 @@ +// Copyright 2026 Dao Browser Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. @@ -78,12 +78,51 @@ index 0000000000..0000000001 + lastReset: number; +} + ++function isRecord(value: unknown): value is Record { ++ return typeof value === 'object' && value !== null && !Array.isArray(value); ++} ++ ++function isNonNegativeFiniteNumber(value: unknown): value is number { ++ return typeof value === 'number' && Number.isFinite(value) && value >= 0; ++} ++ ++function isDaoAgentMemorySummary( ++ value: unknown): value is DaoAgentMemorySummary { ++ return isRecord(value) && ++ isNonNegativeFiniteNumber(value['totalSize']) && ++ isNonNegativeFiniteNumber(value['conversationCount']) && ++ isNonNegativeFiniteNumber(value['episodeCount']) && ++ isNonNegativeFiniteNumber(value['preferenceCount']); ++} ++ ++function isDaoAgentWorkspaceActivity( ++ value: unknown): value is DaoAgentWorkspaceActivity { ++ return isRecord(value) && typeof value['timestamp'] === 'string' && ++ typeof value['operation'] === 'string' && ++ typeof value['path'] === 'string'; ++} ++ ++function isDaoAgentWorkspaceSummary( ++ value: unknown): value is DaoAgentWorkspaceSummary { ++ if (!isRecord(value)) { ++ return false; ++ } ++ const recentActivity = value['recentActivity']; ++ return typeof value['root'] === 'string' && ++ isNonNegativeFiniteNumber(value['usedBytes']) && ++ isNonNegativeFiniteNumber(value['capBytes']) && ++ isNonNegativeFiniteNumber(value['fileCount']) && ++ isNonNegativeFiniteNumber(value['fileCountCap']) && ++ Array.isArray(recentActivity) && ++ recentActivity.every(isDaoAgentWorkspaceActivity); ++} ++ +export interface DaoAgentSettingsBrowserProxy { + getSettings(): Promise; + setSetting(key: string, value: string|null): Promise; -+ getMemorySummary(): Promise; ++ getMemorySummary(): Promise; + clearAllMemory(): Promise; -+ getWorkspaceSummary(): Promise; ++ getWorkspaceSummary(): Promise; + openWorkspace(): Promise; + getUsageStats(): Promise; + resetUsageStats(): Promise; @@ -99,7 +138,7 @@ index 0000000000..0000000001 + return sendWithPromise('setDaoAgentSetting', key, value); + } + -+ getMemorySummary(): Promise { ++ getMemorySummary(): Promise { + return sendWithPromise('getDaoAgentMemorySummary'); + } + @@ -107,7 +146,7 @@ index 0000000000..0000000001 + return sendWithPromise('clearAllDaoAgentMemory'); + } + -+ getWorkspaceSummary(): Promise { ++ getWorkspaceSummary(): Promise { + return sendWithPromise('getDaoAgentWorkspaceSummary'); + } + @@ -502,6 +541,9 @@ index 0000000000..0000000001 + this.memoryError_ = false; + try { + const summary = await this.agentSettingsBrowserProxy_.getMemorySummary(); ++ if (!isDaoAgentMemorySummary(summary)) { ++ throw new Error(); ++ } + if (generation === this.memorySummaryRequestGeneration_) { + this.memorySummary_ = summary; + } @@ -523,6 +565,9 @@ index 0000000000..0000000001 + try { + const summary = + await this.agentSettingsBrowserProxy_.getWorkspaceSummary(); ++ if (!isDaoAgentWorkspaceSummary(summary)) { ++ throw new Error(); ++ } + if (generation === this.workspaceSummaryRequestGeneration_) { + this.workspaceSummary_ = summary; + } diff --git a/src/patches/chrome/test/data/webui/settings/dao_page_test.ts.patch b/src/patches/chrome/test/data/webui/settings/dao_page_test.ts.patch index 249cf688..56a3f43a 100644 --- a/src/patches/chrome/test/data/webui/settings/dao_page_test.ts.patch +++ b/src/patches/chrome/test/data/webui/settings/dao_page_test.ts.patch @@ -3,7 +3,7 @@ new file mode 100644 index 0000000000..0000000001 --- /dev/null +++ b/chrome/test/data/webui/settings/dao_page_test.ts -@@ -0,0 +1,1009 @@ +@@ -0,0 +1,1035 @@ +// Copyright 2026 Dao Browser Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. @@ -21,9 +21,9 @@ index 0000000000..0000000001 + +type DaoMcpSetupOption = 'codex'|'claude-code'|'generic-mcp'; +type DaoAgentMemorySummary = -+ Awaited>; ++ NonNullable; +type DaoAgentWorkspaceSummary = -+ Awaited>; ++ NonNullable; +type DaoAgentUsageStats = + Awaited>; + @@ -84,9 +84,8 @@ index 0000000000..0000000001 + lastReset: 0, + }, + }; -+ private memorySummaryRequests_: Array> = []; -+ private workspaceSummaryRequests_: -+ Array> = []; ++ private memorySummaryRequests_: Array> = []; ++ private workspaceSummaryRequests_: Array> = []; + private usageStatsRequests_: Array> = []; + private clearMemoryRequests_: Array> = []; + private resetUsageRequests_: Array> = []; @@ -114,7 +113,7 @@ index 0000000000..0000000001 + return Promise.resolve(true); + } + -+ getMemorySummary(): Promise { ++ getMemorySummary(): Promise { + this.methodCalled('getMemorySummary'); + return new Promise((resolve, reject) => { + this.memorySummaryRequests_.push({resolve, reject}); @@ -128,7 +127,7 @@ index 0000000000..0000000001 + }); + } + -+ getWorkspaceSummary(): Promise { ++ getWorkspaceSummary(): Promise { + this.methodCalled('getWorkspaceSummary'); + return new Promise((resolve, reject) => { + this.workspaceSummaryRequests_.push({resolve, reject}); @@ -154,7 +153,7 @@ index 0000000000..0000000001 + }); + } + -+ resolveMemorySummary(summary: DaoAgentMemorySummary, request = 0): void { ++ resolveMemorySummary(summary: unknown, request = 0): void { + const deferred = this.memorySummaryRequests_[request]; + assertTrue(!!deferred); + this.memorySummaryRequests_.splice(request, 1); @@ -168,8 +167,7 @@ index 0000000000..0000000001 + deferred.reject(new Error('memory summary failed')); + } + -+ resolveWorkspaceSummary( -+ summary: DaoAgentWorkspaceSummary, request = 0): void { ++ resolveWorkspaceSummary(summary: unknown, request = 0): void { + const deferred = this.workspaceSummaryRequests_[request]; + assertTrue(!!deferred); + this.workspaceSummaryRequests_.splice(request, 1); @@ -347,6 +345,13 @@ index 0000000000..0000000001 + flush(); + } + ++ async function reconnectPage(): Promise { ++ page.remove(); ++ document.body.appendChild(page); ++ await flushTasks(); ++ flush(); ++ } ++ + test('agentManagementSummariesLoadIndependently', async function() { + assertEquals( + 1, agentSettingsBrowserProxy.getCallCount('getMemorySummary')); @@ -418,24 +423,45 @@ index 0000000000..0000000001 + assertTrue(!!page.shadowRoot!.querySelector('#agentUsageRetry')); + }); + -+ test('workspaceRetryFailurePreservesLastGoodData', async function() { ++ test('emptyMemoryResponseIsFailureAndPreservesLastGoodData', ++ async function() { ++ await resolveAllManagementSummaries(); ++ await reconnectPage(); ++ ++ agentSettingsBrowserProxy.resolveMemorySummary({}); ++ agentSettingsBrowserProxy.resolveWorkspaceSummary(workspaceSummary); ++ agentSettingsBrowserProxy.resolveUsageStats(usageStats); ++ await flushTasks(); ++ ++ assertTrue(page.memoryErrorForTest); ++ assertDeepEquals(memorySummary, page.memorySummaryForTest); ++ }); ++ ++ test('emptyWorkspaceResponseIsFailureAndPreservesLastGoodData', ++ async function() { ++ await resolveAllManagementSummaries(); ++ await reconnectPage(); ++ ++ agentSettingsBrowserProxy.resolveMemorySummary(memorySummary); ++ agentSettingsBrowserProxy.resolveWorkspaceSummary({}); ++ agentSettingsBrowserProxy.resolveUsageStats(usageStats); ++ await flushTasks(); ++ ++ assertTrue(page.workspaceErrorForTest); ++ assertDeepEquals(workspaceSummary, page.workspaceSummaryForTest); ++ }); ++ ++ test('workspaceReconnectFailurePreservesLastGoodData', async function() { ++ await resolveAllManagementSummaries(); ++ await reconnectPage(); ++ + agentSettingsBrowserProxy.resolveMemorySummary(memorySummary); + agentSettingsBrowserProxy.rejectWorkspaceSummary(); + agentSettingsBrowserProxy.resolveUsageStats(usageStats); + await flushTasks(); -+ flush(); + -+ const retry = page.shadowRoot!.querySelector( -+ '#agentWorkspaceRetry')!; -+ retry.click(); -+ agentSettingsBrowserProxy.resolveWorkspaceSummary(workspaceSummary); -+ await flushTasks(); -+ flush(); -+ assertDeepEquals(workspaceSummary, page.workspaceSummaryForTest); -+ -+ retry.click(); -+ agentSettingsBrowserProxy.rejectWorkspaceSummary(); -+ await flushTasks(); ++ assertEquals( ++ 2, agentSettingsBrowserProxy.getCallCount('getWorkspaceSummary')); + assertTrue(page.workspaceErrorForTest); + assertDeepEquals(workspaceSummary, page.workspaceSummaryForTest); + }); From 84456784d74bc54d0ec82ac808521b5b9c130850 Mon Sep 17 00:00:00 2001 From: moonrailgun Date: Fri, 7 Aug 2026 12:24:31 +0800 Subject: [PATCH 08/13] feat(settings): restore agent management cards --- .../commands/__tests__/settings_i18n.test.ts | 136 +++ .../settings_redesign_contract.test.ts | 76 ++ .../generated_resources_zh-CN.xtb.patch | 145 ++- .../chrome/app/settings_strings.grdp.patch | 305 +++++- .../settings/dao_page/dao_page.html.patch | 965 +++++++++++++++++- .../settings/dao_page/dao_page.ts.patch | 49 +- ...ttings_localized_strings_provider.cc.patch | 126 ++- 7 files changed, 1766 insertions(+), 36 deletions(-) diff --git a/scripts/commands/__tests__/settings_i18n.test.ts b/scripts/commands/__tests__/settings_i18n.test.ts index 2e17f587..27f0f990 100644 --- a/scripts/commands/__tests__/settings_i18n.test.ts +++ b/scripts/commands/__tests__/settings_i18n.test.ts @@ -4,6 +4,22 @@ import {existsSync, readFileSync} from 'node:fs'; import {describe, expect, it} from 'vitest'; const daoSettingsTranslations = [ + { + id: '4599588625161804776', + translation: 'Dao 浏览器', + }, + { + id: '5655808454255500256', + translation: '基于 Chromium', + }, + { + id: '441389943317387777', + translation: 'Dao 专属', + }, + { + id: '8413644221083874626', + translation: '单项工具权限', + }, { id: '4833302064619809816', translation: '您与 Dao', @@ -159,7 +175,127 @@ const daoSettingsTranslations = [ }, ]; +const daoAgentManagementTranslations = [ + ['DAO_AGENT_MANAGEMENT_TITLE', 'daoAgentManagementTitle', '数据与管理'], + ['DAO_AGENT_MANAGEMENT_MEMORY_TITLE', 'daoAgentManagementMemoryTitle', '记忆'], + ['DAO_AGENT_MANAGEMENT_WORKSPACE_TITLE', 'daoAgentManagementWorkspaceTitle', '工作区'], + ['DAO_AGENT_MANAGEMENT_USAGE_TITLE', 'daoAgentManagementUsageTitle', '用量'], + ['DAO_AGENT_MANAGEMENT_CONVERSATIONS', 'daoAgentManagementConversations', '对话'], + ['DAO_AGENT_MANAGEMENT_PREFERENCES', 'daoAgentManagementPreferences', '偏好'], + ['DAO_AGENT_MANAGEMENT_EPISODES', 'daoAgentManagementEpisodes', '事件'], + ['DAO_AGENT_MANAGEMENT_TOTAL_SIZE', 'daoAgentManagementTotalSize', '总大小'], + ['DAO_AGENT_MANAGEMENT_ROOT', 'daoAgentManagementRoot', '根目录'], + ['DAO_AGENT_MANAGEMENT_STORAGE', 'daoAgentManagementStorage', '存储空间'], + ['DAO_AGENT_MANAGEMENT_FILES', 'daoAgentManagementFiles', '文件'], + ['DAO_AGENT_MANAGEMENT_RECENT_ACTIVITY', 'daoAgentManagementRecentActivity', '近期活动'], + ['DAO_AGENT_MANAGEMENT_API_CALLS', 'daoAgentManagementApiCalls', 'API 调用'], + ['DAO_AGENT_MANAGEMENT_TOOL_CALLS', 'daoAgentManagementToolCalls', '工具调用'], + ['DAO_AGENT_MANAGEMENT_PROMPT_TOKENS', 'daoAgentManagementPromptTokens', '提示词元'], + ['DAO_AGENT_MANAGEMENT_COMPLETION_TOKENS', 'daoAgentManagementCompletionTokens', '补全词元'], + ['DAO_AGENT_MANAGEMENT_TOTAL_TOKENS', 'daoAgentManagementTotalTokens', '总词元'], + ['DAO_AGENT_MANAGEMENT_ESTIMATED_COST', 'daoAgentManagementEstimatedCost', '预估费用'], + ['DAO_AGENT_MANAGEMENT_LAST_RESET', 'daoAgentManagementLastReset', '上次重置'], + ['DAO_AGENT_MANAGEMENT_LOADING', 'daoAgentManagementLoading', '正在加载…'], + ['DAO_AGENT_MANAGEMENT_MEMORY_ERROR', 'daoAgentManagementMemoryError', '无法加载或清除记忆。请重试刷新,再次清除记忆。'], + ['DAO_AGENT_MANAGEMENT_WORKSPACE_ERROR', 'daoAgentManagementWorkspaceError', '无法加载或打开工作区。请重试刷新,再次打开工作区。'], + ['DAO_AGENT_MANAGEMENT_USAGE_ERROR', 'daoAgentManagementUsageError', '无法加载或重置用量。请重试刷新,再次重置用量。'], + ['DAO_AGENT_MANAGEMENT_RETRY', 'daoAgentManagementRetry', '重试'], + ['DAO_AGENT_MANAGEMENT_CLEAR_MEMORY', 'daoAgentManagementClearMemory', '清除记忆'], + ['DAO_AGENT_MANAGEMENT_OPEN_WORKSPACE', 'daoAgentManagementOpenWorkspace', '打开工作区'], + ['DAO_AGENT_MANAGEMENT_RESET_USAGE', 'daoAgentManagementResetUsage', '重置用量'], + ['DAO_AGENT_MANAGEMENT_CLEAR_MEMORY_DIALOG_TITLE', 'daoAgentManagementClearMemoryDialogTitle', '清除全部记忆?'], + ['DAO_AGENT_MANAGEMENT_CLEAR_MEMORY_DIALOG_DESCRIPTION', 'daoAgentManagementClearMemoryDialogDescription', '这会永久删除所有对话记忆、偏好和事件,且无法撤销。'], + ['DAO_AGENT_MANAGEMENT_CLEAR_MEMORY_CANCEL', 'daoAgentManagementClearMemoryCancel', '取消'], + ['DAO_AGENT_MANAGEMENT_CLEAR_MEMORY_CONFIRM', 'daoAgentManagementClearMemoryConfirm', '清除记忆'], + ['DAO_AGENT_MANAGEMENT_RESET_USAGE_DIALOG_TITLE', 'daoAgentManagementResetUsageDialogTitle', '重置用量统计?'], + ['DAO_AGENT_MANAGEMENT_RESET_USAGE_DIALOG_DESCRIPTION', 'daoAgentManagementResetUsageDialogDescription', '这会将所有 API、工具和词元计数清零。'], + ['DAO_AGENT_MANAGEMENT_RESET_USAGE_CANCEL', 'daoAgentManagementResetUsageCancel', '取消'], + ['DAO_AGENT_MANAGEMENT_RESET_USAGE_CONFIRM', 'daoAgentManagementResetUsageConfirm', '重置用量'], + ['DAO_AGENT_MANAGEMENT_NO_RECENT_ACTIVITY', 'daoAgentManagementNoRecentActivity', '暂无近期活动'], + ['DAO_AGENT_MANAGEMENT_NO_TOOL_CALLS', 'daoAgentManagementNoToolCalls', '暂无工具调用'], + ['DAO_AGENT_MANAGEMENT_MEMORY_CLEARED', 'daoAgentManagementMemoryCleared', '记忆已清除'], + ['DAO_AGENT_MANAGEMENT_WORKSPACE_OPENED', 'daoAgentManagementWorkspaceOpened', '工作区已打开'], + ['DAO_AGENT_MANAGEMENT_USAGE_RESET', 'daoAgentManagementUsageReset', '用量已重置'], +] as const; + describe('settings i18n patches', () => { + it('localizes every Agent management state through the settings provider', () => { + const grdpPatch = readFileSync(path.join( + process.cwd(), 'src/patches/chrome/app/settings_strings.grdp.patch'), + 'utf-8'); + const providerPatch = readFileSync(path.join( + process.cwd(), + 'src/patches/chrome/browser/ui/webui/settings/' + + 'settings_localized_strings_provider.cc.patch'), 'utf-8'); + const zhCnPatch = readFileSync(path.join( + process.cwd(), + 'src/patches/chrome/app/resources/' + + 'generated_resources_zh-CN.xtb.patch'), 'utf-8'); + + for (const [resourceSuffix, providerKey, translation] of + daoAgentManagementTranslations) { + const resourceName = `IDS_SETTINGS_${resourceSuffix}`; + expect(grdpPatch, resourceName) + .toContain(`${translation}`)); + } + }); + + it('does not repeat translation IDs inside the zh-CN patch', () => { + const patch = readFileSync(path.join( + process.cwd(), + 'src/patches/chrome/app/resources/generated_resources_zh-CN.xtb.patch'), + 'utf-8'); + const ids = [...patch.matchAll(/translation id="(\d+)"/g)] + .map(match => match[1]); + expect(new Set(ids).size).toBe(ids.length); + }); + + it('keeps Agent search options aligned with the runtime override type', () => { + const pagePatch = readFileSync(path.join( + process.cwd(), + 'src/patches/chrome/browser/resources/settings/dao_page/' + + 'dao_page.html.patch'), 'utf-8'); + const typeSource = readFileSync(path.join( + process.cwd(), + 'src/dao/browser/ui/webui/resources/agent/web_search/types.ts'), + 'utf-8'); + const options = [...pagePatch.matchAll( + /