Skip to content

Commit 15fe7f8

Browse files
committed
fix(config): autosave inline settings changes
1 parent a8a0bb5 commit 15fe7f8

22 files changed

Lines changed: 361 additions & 180 deletions

openspec/changes/netclaw-config-command/tasks.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -127,19 +127,19 @@
127127

128128
## 16. Shared autosave config interaction
129129

130-
- [ ] 16.1 Introduce a shared autosave interaction component/contract for
130+
- [x] 16.1 Introduce a shared autosave interaction component/contract for
131131
inline config editors.
132-
- [ ] 16.2 Remove explicit save-key behavior and copy from inline config
132+
- [x] 16.2 Remove explicit save-key behavior and copy from inline config
133133
editors; completed actions autosave instead.
134-
- [ ] 16.3 Ensure `Esc` only navigates/cancels and never persists edits.
135-
- [ ] 16.4 Ensure each autosave validates before writing and leaves files
134+
- [x] 16.3 Ensure `Esc` only navigates/cancels and never persists edits.
135+
- [x] 16.4 Ensure each autosave validates before writing and leaves files
136136
unchanged on validation failure.
137-
- [ ] 16.5 Ensure writes are section-preserving and field-scoped to editor
137+
- [x] 16.5 Ensure writes are section-preserving and field-scoped to editor
138138
ownership boundaries.
139-
- [ ] 16.6 Harden Channels persistence so provider enable/disable, add/remove,
139+
- [x] 16.6 Harden Channels persistence so provider enable/disable, add/remove,
140140
audience, allowed-user, direct-message, and credential actions autosave
141141
provider-granular changes without wiping unrelated providers.
142-
- [ ] 16.7 Add the regression: seed Slack and Discord, add a Discord channel,
142+
- [x] 16.7 Add the regression: seed Slack and Discord, add a Discord channel,
143143
disable Slack, press `Esc`, and verify only completed autosaves occurred
144144
with Slack dormant setup preserved.
145145

src/Netclaw.Cli.Tests/Tui/Config/BrowserAutomationConfigViewModelTests.cs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,11 @@ public void Save_refuses_enablement_when_prerequisites_are_missing()
4444
var before = File.ReadAllText(_paths.NetclawConfigPath);
4545
using var vm = new BrowserAutomationConfigViewModel(_paths, new FakeProbe(false));
4646

47-
vm.ToggleEnabled();
48-
49-
Assert.False(vm.Save());
47+
Assert.False(vm.ToggleEnabled());
5048
Assert.Equal(ConfigStatusTone.Error, vm.Status.Value.Tone);
5149
Assert.Contains("missing", vm.Status.Value.Text, StringComparison.OrdinalIgnoreCase);
5250
Assert.Equal(before, File.ReadAllText(_paths.NetclawConfigPath));
51+
Assert.False(vm.Enabled.Value);
5352
}
5453

5554
[Fact]

src/Netclaw.Cli.Tests/Tui/Config/ChannelsConfigNavigationTests.cs

Lines changed: 45 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
// -----------------------------------------------------------------------
66
using Microsoft.Extensions.DependencyInjection;
77
using Netclaw.Actors.Channels;
8+
using Netclaw.Cli.Config;
89
using Netclaw.Cli.Discord;
910
using Netclaw.Cli.Tests.Tui;
1011
using Netclaw.Cli.Tui;
@@ -96,8 +97,8 @@ public async Task Channels_RotateCredentials_AcceptsTypedCredentialInput(Channel
9697
await app.RunAsync(cts.Token);
9798

9899
var channelsVm = Assert.IsType<ChannelsConfigViewModel>(getChannelsVm());
99-
AssertTypedCredentials(channelsVm, channelType);
100-
Assert.Equal("Credential changes staged. Press Esc, then s to save.", channelsVm.Status.Value.Text);
100+
AssertPersistedCredentials(channelType, typed: true);
101+
Assert.Equal("Credential changes saved.", channelsVm.Status.Value.Text);
101102
}
102103

103104
[Theory]
@@ -121,7 +122,7 @@ public async Task Channels_FirstTimeAdapterSetup_AcceptsTypedCredentialInput(Cha
121122
var channelsVm = Assert.IsType<ChannelsConfigViewModel>(getChannelsVm());
122123
Assert.Equal(ChannelsConfigScreen.ChannelPermissions, channelsVm.Screen.Value);
123124
Assert.Equal(channelType, channelsVm.ActiveAdapterType);
124-
AssertFirstTimeSetup(channelsVm, channelType);
125+
AssertFirstTimeSetupPersisted(channelsVm, channelType);
125126
}
126127

127128
[Fact]
@@ -156,16 +157,16 @@ public async Task Channels_AddChannel_AcceptsPastedChannelInput()
156157
input.EnqueueKey(ConsoleKey.Enter); // Open configured Slack management.
157158
input.EnqueueKey(ConsoleKey.DownArrow); // Add channel.
158159
input.EnqueueKey(ConsoleKey.Enter);
159-
input.EnqueuePaste("#pasted-channel");
160+
input.EnqueuePaste("#C09");
160161
input.EnqueueKey(ConsoleKey.Enter);
161162
input.EnqueueKey(ConsoleKey.Q, false, false, true);
162163

163164
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
164165
await app.RunAsync(cts.Token);
165166

166167
var channelsVm = Assert.IsType<ChannelsConfigViewModel>(getChannelsVm());
167-
Assert.Contains(channelsVm.GetChannelRows(), row => row.Id == "pasted-channel" && !row.IsAddAction);
168-
Assert.Equal("Added pasted-channel. Press Esc, then s to save.", channelsVm.Status.Value.Text);
168+
Assert.Contains(channelsVm.GetChannelRows(), row => row.Id == "C09" && !row.IsAddAction);
169+
Assert.Equal("Added C09 and saved.", channelsVm.Status.Value.Text);
169170
}
170171

171172
[Fact]
@@ -202,7 +203,7 @@ public async Task Channels_ChannelPermissions_DeleteRemovesSelectedChannel()
202203

203204
var channelsVm = Assert.IsType<ChannelsConfigViewModel>(getChannelsVm());
204205
Assert.DoesNotContain(channelsVm.GetChannelRows(), row => row.Id == "C01");
205-
Assert.Equal("Removed C01. Press Esc, then s to save.", channelsVm.Status.Value.Text);
206+
Assert.Equal("Removed C01 and saved.", channelsVm.Status.Value.Text);
206207
}
207208

208209
[Fact]
@@ -346,7 +347,7 @@ private static void TypeFirstTimeSetup(VirtualInputSource input, ChannelType cha
346347
input.EnqueueKey(ConsoleKey.Enter);
347348
input.EnqueueString("xapp-first-time-token");
348349
input.EnqueueKey(ConsoleKey.Enter);
349-
input.EnqueueString("C-first-time");
350+
input.EnqueueString("C123456");
350351
input.EnqueueKey(ConsoleKey.Enter);
351352
SelectSecondOption(input); // Disable DMs.
352353
SelectSecondOption(input); // Allow anyone in allowed channels.
@@ -381,55 +382,73 @@ private static void SelectSecondOption(VirtualInputSource input)
381382
input.EnqueueKey(ConsoleKey.Enter);
382383
}
383384

384-
private static void AssertTypedCredentials(ChannelsConfigViewModel vm, ChannelType channelType)
385+
private void AssertPersistedCredentials(ChannelType channelType, bool typed)
385386
{
387+
var config = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath);
388+
var secrets = ConfigFileHelper.LoadJsonDict(_paths.SecretsPath);
386389
switch (channelType)
387390
{
388391
case ChannelType.Slack:
389-
var slack = vm.Step.GetAdapterViewModel<SlackStepViewModel>(ChannelType.Slack);
390-
Assert.Equal("xoxb-typed-token", slack.BotToken);
391-
Assert.Equal("xapp-typed-token", slack.AppToken);
392+
AssertSecret(secrets, "Slack.BotToken", typed ? "xoxb-typed-token" : "xoxb-first-time-token");
393+
AssertSecret(secrets, "Slack.AppToken", typed ? "xapp-typed-token" : "xapp-first-time-token");
392394
break;
393395
case ChannelType.Discord:
394-
var discord = vm.Step.GetAdapterViewModel<DiscordStepViewModel>(ChannelType.Discord);
395-
Assert.Equal("discord-typed-token", discord.BotToken);
396+
AssertSecret(secrets, "Discord.BotToken", typed ? "discord-typed-token" : "discord-first-time-token");
396397
break;
397398
case ChannelType.Mattermost:
398-
var mattermost = vm.Step.GetAdapterViewModel<MattermostStepViewModel>(ChannelType.Mattermost);
399-
Assert.Equal("https://typed-mattermost.example.com", mattermost.ServerUrl);
400-
Assert.Equal("mattermost-typed-token", mattermost.BotToken);
399+
Assert.True(ConfigFileHelper.TryGetPathValue(config, "Mattermost.ServerUrl", out var serverUrl));
400+
Assert.Equal(typed ? "https://typed-mattermost.example.com" : "https://first-time-mattermost.example.com", serverUrl);
401+
AssertSecret(secrets, "Mattermost.BotToken", typed ? "mattermost-typed-token" : "mattermost-first-time-token");
401402
break;
402403
default:
403404
throw new ArgumentOutOfRangeException(nameof(channelType), channelType, null);
404405
}
405406
}
406407

407-
private static void AssertFirstTimeSetup(ChannelsConfigViewModel vm, ChannelType channelType)
408+
private void AssertFirstTimeSetupPersisted(ChannelsConfigViewModel vm, ChannelType channelType)
408409
{
410+
AssertPersistedCredentials(channelType, typed: false);
411+
var config = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath);
409412
switch (channelType)
410413
{
411414
case ChannelType.Slack:
412415
var slack = vm.Step.GetAdapterViewModel<SlackStepViewModel>(ChannelType.Slack);
413-
Assert.Equal("xoxb-first-time-token", slack.BotToken);
414-
Assert.Equal("xapp-first-time-token", slack.AppToken);
415-
Assert.Equal("C-first-time", slack.ChannelNamesInput);
416+
Assert.True(slack.HasPersistedBotToken);
417+
Assert.True(slack.HasPersistedAppToken);
418+
Assert.True(ConfigFileHelper.TryGetPathValue(config, "Slack.AllowedChannelIds", out var slackChannelsRaw));
419+
Assert.Equal(["C123456"], ToStringArray(slackChannelsRaw));
416420
break;
417421
case ChannelType.Discord:
418422
var discord = vm.Step.GetAdapterViewModel<DiscordStepViewModel>(ChannelType.Discord);
419-
Assert.Equal("discord-first-time-token", discord.BotToken);
420-
Assert.Equal("123456789012345678", discord.ChannelIdsInput);
423+
Assert.True(discord.HasPersistedBotToken);
424+
Assert.True(ConfigFileHelper.TryGetPathValue(config, "Discord.AllowedChannelIds", out var discordChannelsRaw));
425+
Assert.Equal(["123456789012345678"], ToStringArray(discordChannelsRaw));
421426
break;
422427
case ChannelType.Mattermost:
423428
var mattermost = vm.Step.GetAdapterViewModel<MattermostStepViewModel>(ChannelType.Mattermost);
424-
Assert.Equal("https://first-time-mattermost.example.com", mattermost.ServerUrl);
425-
Assert.Equal("mattermost-first-time-token", mattermost.BotToken);
426-
Assert.Equal("town-square", mattermost.ChannelIdsInput);
429+
Assert.True(mattermost.HasPersistedBotToken);
430+
Assert.True(ConfigFileHelper.TryGetPathValue(config, "Mattermost.AllowedChannelIds", out var mattermostChannelsRaw));
431+
Assert.Equal(["town-square"], ToStringArray(mattermostChannelsRaw));
427432
break;
428433
default:
429434
throw new ArgumentOutOfRangeException(nameof(channelType), channelType, null);
430435
}
431436
}
432437

438+
private void AssertSecret(Dictionary<string, object> secrets, string path, string expected)
439+
{
440+
Assert.True(ConfigFileHelper.TryGetPathValue(secrets, path, out var raw));
441+
Assert.Equal(expected, ConfigFileHelper.DecryptIfEncrypted(_paths, raw?.ToString()));
442+
}
443+
444+
private static string[] ToStringArray(object? raw)
445+
=> Assert.IsType<object[]>(raw).Select(static value => value switch
446+
{
447+
string text => text,
448+
System.Text.Json.JsonElement { ValueKind: System.Text.Json.JsonValueKind.String } element => element.GetString()!,
449+
_ => throw new InvalidOperationException("Expected string array value.")
450+
}).ToArray();
451+
433452
private void WriteEmptyChannelFiles()
434453
{
435454
File.WriteAllText(_paths.NetclawConfigPath,

src/Netclaw.Cli.Tests/Tui/Config/ChannelsConfigViewModelTests.cs

Lines changed: 72 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,7 @@ public void Save_blocks_invalid_mattermost_url_before_probe()
248248
}
249249

250250
[Fact]
251-
public void Back_from_saved_returns_to_channel_picker()
251+
public void Back_from_saved_picker_returns_to_dashboard_or_quits()
252252
{
253253
WriteChannelConfig();
254254
WriteChannelSecrets();
@@ -257,8 +257,67 @@ public void Back_from_saved_returns_to_channel_picker()
257257

258258
vm.GoBack();
259259

260-
Assert.False(vm.IsSaved.Value);
261-
Assert.False(vm.ShutdownRequestedForTest);
260+
Assert.True(vm.IsSaved.Value);
261+
Assert.True(vm.ShutdownRequestedForTest);
262+
}
263+
264+
[Fact]
265+
public void Esc_from_incomplete_add_channel_draft_writes_nothing()
266+
{
267+
WriteAllChannelConfig();
268+
WriteAllChannelSecrets();
269+
var configBefore = File.ReadAllText(_paths.NetclawConfigPath);
270+
var secretsBefore = File.ReadAllText(_paths.SecretsPath);
271+
using var vm = CreateViewModel();
272+
vm.OpenAdapterManagement(ChannelType.Slack);
273+
vm.BeginAddChannel();
274+
vm.AddChannelInput = "C99";
275+
276+
vm.GoBack();
277+
278+
Assert.Equal(ChannelsConfigScreen.ChannelPermissions, vm.Screen.Value);
279+
Assert.Equal(configBefore, File.ReadAllText(_paths.NetclawConfigPath));
280+
Assert.Equal(secretsBefore, File.ReadAllText(_paths.SecretsPath));
281+
}
282+
283+
[Fact]
284+
public void Discord_add_then_slack_disable_then_escape_preserves_provider_config()
285+
{
286+
WriteAllChannelConfig();
287+
WriteAllChannelSecrets();
288+
using var vm = CreateViewModel();
289+
vm.OpenAdapterManagement(ChannelType.Discord);
290+
vm.BeginAddChannel();
291+
vm.AddChannelInput = "987654321";
292+
293+
vm.ApplyAddChannel();
294+
vm.OpenAdapterManagement(ChannelType.Slack);
295+
MoveToManagementAction(vm, ChannelsManagementAction.ToggleEnabled);
296+
vm.ActivateManagementMenuItem();
297+
vm.GoBack();
298+
299+
var config = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath);
300+
Assert.True(ConfigFileHelper.TryGetPathValue(config, "Slack.Enabled", out var slackEnabled));
301+
Assert.False(Assert.IsType<bool>(slackEnabled));
302+
Assert.True(ConfigFileHelper.TryGetPathValue(config, "Slack.AllowedChannelIds", out var slackChannelsRaw));
303+
Assert.Equal(["C01"], ToStringArray(slackChannelsRaw));
304+
Assert.True(ConfigFileHelper.TryGetPathValue(config, "Slack.ChannelAudiences", out var slackAudiencesRaw));
305+
Assert.Equal("team", ToStringDictionary(slackAudiencesRaw)["C01"]);
306+
307+
Assert.True(ConfigFileHelper.TryGetPathValue(config, "Discord.Enabled", out var discordEnabled));
308+
Assert.True(Assert.IsType<bool>(discordEnabled));
309+
Assert.True(ConfigFileHelper.TryGetPathValue(config, "Discord.AllowedChannelIds", out var discordChannelsRaw));
310+
Assert.Equal(["123456789", "987654321"], ToStringArray(discordChannelsRaw));
311+
Assert.True(ConfigFileHelper.TryGetPathValue(config, "Discord.ChannelAudiences", out var discordAudiencesRaw));
312+
var discordAudiences = ToStringDictionary(discordAudiencesRaw);
313+
Assert.Equal("team", discordAudiences["123456789"]);
314+
Assert.Equal("team", discordAudiences["987654321"]);
315+
316+
var secrets = ConfigFileHelper.LoadJsonDict(_paths.SecretsPath);
317+
Assert.True(ConfigFileHelper.TryGetPathValue(secrets, "Slack.BotToken", out var slackBotToken));
318+
Assert.Equal("xoxb-test", ConfigFileHelper.DecryptIfEncrypted(_paths, slackBotToken?.ToString()));
319+
Assert.True(ConfigFileHelper.TryGetPathValue(secrets, "Discord.BotToken", out var discordBotToken));
320+
Assert.Equal("discord-token", ConfigFileHelper.DecryptIfEncrypted(_paths, discordBotToken?.ToString()));
262321
}
263322

264323
[Fact]
@@ -503,7 +562,6 @@ public void Save_rejects_unresolved_slack_channel_name()
503562
vm.AddChannelInput = "fart";
504563

505564
vm.ApplyAddChannel();
506-
vm.Save();
507565

508566
Assert.False(vm.IsSaved.Value);
509567
Assert.Equal("Slack channel not found: #fart", vm.Status.Value.Text);
@@ -707,6 +765,16 @@ private static void ConfirmReset(ChannelsConfigViewModel vm, ChannelType type)
707765
vm.ApplyResetConfirmation();
708766
}
709767

768+
private static void MoveToManagementAction(ChannelsConfigViewModel vm, ChannelsManagementAction action)
769+
{
770+
var index = vm.GetManagementMenuItems()
771+
.Select((item, itemIndex) => (item, itemIndex))
772+
.Single(entry => entry.item.Action == action)
773+
.itemIndex;
774+
775+
vm.MoveManagementMenu(index);
776+
}
777+
710778
private static int GetAdapterIndex(ChannelsConfigViewModel vm, ChannelType type)
711779
=> vm.Step.Adapters
712780
.Select((adapter, index) => (adapter.Type, index))

src/Netclaw.Cli.Tests/Tui/Config/InboundWebhooksConfigViewModelTests.cs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,12 +76,11 @@ public void Save_blocks_enabled_state_when_no_valid_routes_exist()
7676
var before = File.ReadAllText(_paths.NetclawConfigPath);
7777
using var vm = new InboundWebhooksConfigViewModel(_paths);
7878

79-
vm.ToggleEnabled();
80-
81-
Assert.False(vm.Save());
79+
Assert.False(vm.ToggleEnabled());
8280
Assert.Equal(ConfigStatusTone.Error, vm.Status.Value.Tone);
8381
Assert.Contains("at least one valid route", vm.Status.Value.Text, StringComparison.OrdinalIgnoreCase);
8482
Assert.Equal(before, File.ReadAllText(_paths.NetclawConfigPath));
83+
Assert.False(vm.Enabled.Value);
8584
Assert.Empty(Directory.EnumerateFiles(_paths.WebhooksDirectory));
8685
}
8786

src/Netclaw.Cli/Tui/Config/BrowserAutomationConfigPage.cs

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,6 @@ private LayoutNode BuildContent()
5858
$"Backend {ViewModel.SelectedBackendLabel}",
5959
$"Profile: {ViewModel.SelectedCanonicalServerName}"));
6060
layout = layout.WithChild(Row(2,
61-
"Save apply MCP profile changes",
62-
"Refuses enablement when local runtime prerequisites are missing."));
63-
layout = layout.WithChild(Row(3,
6461
"MCP permissions open grant editor",
6562
"Grant browser_automation access per audience in `netclaw mcp permissions`."));
6663

@@ -97,7 +94,7 @@ private LayoutNode BuildStatusBar()
9794
.Height(1);
9895

9996
private LayoutNode BuildKeyBindings()
100-
=> NetclawTuiChrome.BuildKeyHintLine(" [↑/↓] Navigate [Space/Enter] Select [←/→] Backend [Esc] Settings Areas [Ctrl+Q] Quit");
97+
=> NetclawTuiChrome.BuildKeyHintLine(" [↑/↓] Navigate [Space/Enter] Select/Save [←/→] Backend/Save [Esc] Settings Areas [Ctrl+Q] Quit");
10198

10299
private void HandleKeyPress(KeyPressed key)
103100
{

0 commit comments

Comments
 (0)