- 4001
core.set_sessions_home(dir.path().join("home")); - 4002
let state = AppState::new(core.clone()); - 4003
let old = core - 4004
.start_session_with_route("provider-a".into(), "model-a".into()) - 4005
.await - 4006
.unwrap(); - 4007
let old_id = old.header().unwrap().session_id.clone(); - 4008
crate::register_handle( - 4009
&state, - 4010
old_id.clone(), - 4011
old, - 4012
core.cwd().clone(), - 4013
core.clone(), - 4014
); - 4015
state - 4016
.gateway - 4017
.bind(&core, "telegram:42".into(), old_id.clone(), "rev".into()); - 4018
- 4019
// Core is re-resolved with the new agent identity (e.g. Researcher) - 4020
let researcher_identity = vak_session::types::AgentIdentity { - 4021
id: "researcher".into(), - 4022
revision: 1, - 4023
name: "Researcher".into(), - 4024
character: "vak".into(), - 4025
personality: "curious".into(), - 4026
animation: "subtle".into(), - 4027
voice: "default".into(), - 4028
behaviour: "thorough".into(), - 4029
responsibilities: "deep research".into(), - 4030
instructions: String::new(), - 4031
}; - 4032
let core_researcher = core.clone().with_agent_identity(Some(researcher_identity)); - 4033
- 4034
let fresh = resolve_session(&state, &core_researcher, "telegram:42") - 4035
.await - 4036
.unwrap(); - 4037
assert_ne!(fresh.id, old_id); - 4038
{ - 4039
let lock = fresh - 4040
.session - 4041
.lock() - 4042
.unwrap_or_else(std::sync::PoisonError::into_inner); - 4043
let agent = lock - 4044
.as_ref() - 4045
.unwrap() - 4046
.header() - 4047
.unwrap() - 4048
.agent - 4049
.as_ref() - 4050
.unwrap(); - 4051
assert_eq!(agent.id, "researcher"); - 4052
} - 4053
let old_path = - 4054
vak_session::SessionPath::new_session_file(&core.sessions_home(), core.cwd(), &old_id); - 4055
assert!(old_path.is_file(), "old agent ledger remains intact"); - 4056
} - 4057
- 4058
/// One persona, not two. The `identity` prompt block wins over the - 4059
/// deprecated `VoiceConfig.persona`, and the legacy field still works - 4060
/// when no prompt tier sets one (docs/design/45-prompt-layers.md). - 4061
#[tokio::test] - 4062
async fn identity_block_is_the_persona_with_voice_config_as_fallback() { - 4063
let dir = tempfile::tempdir().unwrap(); - 4064
let core = Core::new(dir.path().to_path_buf()).unwrap(); - 4065
core.set_sessions_home(dir.path().join("home")); - 4066
let state = AppState::new(core.clone()); - 4067
- 4068
state.gateway.bot_upsert( - 4069
&core, - 4070
Bot { - 4071
id: "support".into(), - 4072
surface: "telegram".into(), - 4073
label: "Support".into(), - 4074
token_env: "BOT_TOKEN__SUPPORT".into(), - 4075
agent_id: None, - 4076
policy: Default::default(), - 4077
permission_mode: None, - 4078
route: None, - 4079
workspace: None, - 4080
voice: Some(vak_config::VoiceConfig { - 4081
voice_name: Some("Kore".into()), - 4082
persona: Some("legacy persona".into()), - 4083
..Default::default() - 4084
}), - 4085
prompt: Default::default(), - 4086
}, - 4087
); - 4088
state.gateway.allowlist_approve( - 4089
&core, - 4090
"telegram:42", - 4091
core.cwd().clone(), - 4092
None, - 4093
None, - 4094
None, - 4095
Default::default(), - 4096
Some("support".into()), - 4097
true, - 4098
"test", - 4099
); - 4100
- 4101
// No prompt tier yet: the legacy field still drives the voice. - 4102
assert_eq!( - 4103
state.gateway.resolve_persona("telegram:42").as_deref(), - 4104
Some("legacy persona") - 4105
); - 4106
- 4107
// Give the bot an identity block; it takes over. - 4108
let mut bot = state.gateway.bot_get("support").unwrap(); - 4109
bot.prompt.identity = Some("You are the ACME support bot: warm and brief.".into()); - 4110
state.gateway.bot_upsert(&core, bot); - 4111
assert_eq!( - 4112
state.gateway.resolve_persona("telegram:42").as_deref(), - 4113
Some("You are the ACME support bot: warm and brief.") - 4114
); - 4115
- 4116
// The chat tier is narrower still. - 4117
let entry = state.gateway.allowlist_get("telegram:42").unwrap(); - 4118
state.gateway.allowlist_patch( - 4119
&core, - 4120
"telegram:42", - 4121
entry.workspace, - 4122
Some(entry.agent_id), - 4123
entry.route, - 4124
entry.permission_mode, - 4125
entry.policy, - 4126
Some(entry.bot_id), - 4127
Some(entry.inherit_bot_policy), - 4128
Some(entry.voice), - 4129
Some(vak_core::prompts::LayerContent { - 4130
identity: Some("You are ACME support for this VIP chat.".into()), - 4131
..Default::default() - 4132
}), - 4133
); - 4134
assert_eq!( - 4135
state.gateway.resolve_persona("telegram:42").as_deref(), - 4136
Some("You are ACME support for this VIP chat.") - 4137
); - 4138
// Voice *name* selection is untouched — that was never duplicated. - 4139
assert_eq!( - 4140
state - 4141
.gateway - 4142
.resolve_voice("telegram:42") - 4143
.and_then(|v| v.voice_name) - 4144
.as_deref(), - 4145
Some("Kore") - 4146
); - 4147
} - 4148
- 4149
/// A chat binding is implicit — the operator never named the session — - 4150
/// so an edited prompt layer rotates it rather than failing, and the old - 4151
/// A chat binding preserves its session across prompt layer edits, - 4152
/// refreshing capabilities dynamically without forced session rotation. - 4153
#[tokio::test] - 4154
async fn prompt_layer_change_preserves_binding_without_forced_rotation() { - 4155
let dir = tempfile::tempdir().unwrap(); - 4156
let core = Core::new(dir.path().to_path_buf()).unwrap(); - 4157
core.set_sessions_home(dir.path().join("home")); - 4158
let session = core - 4159
.start_session_with_route(core.effective_provider(), core.effective_model()) - 4160
.await - 4161
.unwrap(); - 4162
let old_id = session.header().unwrap().session_id.clone(); - 4163
assert!( - 4164
!session.header().unwrap().contract.prompt_layers.is_empty(), - 4165
"a new session must record which prompt layers it froze" - 4166
); - 4167
let state = AppState::new(core.clone()); - 4168
crate::register_handle( - 4169
&state, - 4170
old_id.clone(), - 4171
session, - 4172
core.cwd().clone(), - 4173
core.clone(), - 4174
); - 4175
state.gateway.bind( - 4176
&core, - 4177
"telegram:42".into(), - 4178
old_id.clone(), - 4179
"route-revision".into(), - 4180
); - 4181
- 4182
// Unchanged workspace: the same session is reused. - 4183
let same = resolve_session(&state, &core, "telegram:42").await.unwrap(); - 4184
assert_eq!(same.id, old_id); - 4185
- 4186
// Now edit a layer under the running binding. - 4187
let prompts_dir = core.cwd().join(".vak/prompts"); - 4188
std::fs::create_dir_all(&prompts_dir).unwrap(); - 4189
std::fs::write(prompts_dir.join("guardrails.md"), "- never touch infra/\n").unwrap(); - 4190
- 4191
let fresh = resolve_session(&state, &core, "telegram:42").await.unwrap(); - 4192
assert_eq!( - 4193
fresh.id, old_id, - 4194
"prompt layer change preserves session without forced rotation" - 4195
); - 4196
let old_path = - 4197
vak_session::SessionPath::new_session_file(&core.sessions_home(), core.cwd(), &old_id); - 4198
assert!(old_path.is_file(), "append-only ledger remains intact"); - 4199
} - 4200
- 4201
#[tokio::test] - 4202
async fn capability_contract_change_rotates_legacy_binding() { - 4203
let dir = tempfile::tempdir().unwrap(); - 4204
let core = Core::new(dir.path().to_path_buf()).unwrap(); - 4205
core.set_sessions_home(dir.path().join("home")); - 4206
let current = core - 4207
.start_session_with_route("provider-a".into(), "model-a".into()) - 4208
.await - 4209
.unwrap(); - 4210
let mut legacy_header = current.header().unwrap().clone(); - 4211
legacy_header.session_id = "legacy-session".into(); - 4212
legacy_header.contract.app_version = "0.11.35".into(); - 4213
legacy_header.contract.capabilities.clear(); - 4214
let legacy_path = vak_session::SessionPath::new_session_file( - 4215
&core.sessions_home(), - 4216
core.cwd(), - 4217
&legacy_header.session_id, - 4218
); - 4219
let legacy = vak_session::SessionLog::create(legacy_path.clone(), legacy_header).unwrap(); - 4220
let state = AppState::new(core.clone()); - 4221
crate::register_handle( - 4222
&state, - 4223
"legacy-session".into(), - 4224
legacy, - 4225
core.cwd().clone(), - 4226
core.clone(), - 4227
); - 4228
state.gateway.bind( - 4229
&core, - 4230
"telegram:42".into(), - 4231
"legacy-session".into(), - 4232
"route-revision".into(), - 4233
); - 4234
- 4235
let fresh = resolve_session(&state, &core, "telegram:42").await.unwrap(); - 4236
- 4237
assert_ne!(fresh.id, "legacy-session"); - 4238
let lock = fresh - 4239
.session - 4240
.lock() - 4241
.unwrap_or_else(std::sync::PoisonError::into_inner); - 4242
let contract = &lock.as_ref().unwrap().header().unwrap().contract; - 4243
assert_eq!(contract.capabilities, core.capability_descriptors()); - 4244
assert!(legacy_path.is_file(), "legacy ledger remains append-only"); - 4245
} - 4246
- 4247
// ---- Allowlist store (docs/design/34-channel-onboarding.md) ----------- - 4248
- 4249
fn core_with_config(toml: &str) -> (tempfile::TempDir, Core) { - 4250
let dir = tempfile::tempdir().unwrap(); - 4251
let cwd = dir.path().to_path_buf(); - 4252
std::fs::create_dir_all(cwd.join(".vak")).unwrap(); - 4253
std::fs::write(cwd.join(".vak/config.toml"), toml).unwrap(); - 4254
let core = Core::new_with_trust(cwd.clone(), true).unwrap(); - 4255
core.set_sessions_home(dir.path().join("home")); - 4256
(dir, core) - 4257
} - 4258
- 4259
#[test] - 4260
fn allowlist_seeds_from_config_only_when_file_absent() { - 4261
let (_dir, core) = - 4262
core_with_config("[gateway]\nchat_allowlist = [\"telegram:1\", \"telegram:2\"]\n"); - 4263
let gw = GatewayState::load(&core, true); - 4264
let mut entries = gw.allowlist_snapshot(); - 4265
entries.sort_by(|a, b| a.key.cmp(&b.key)); - 4266
assert_eq!(entries.len(), 2); - 4267
assert_eq!(entries[0].key, "telegram:1"); - 4268
assert_eq!(entries[0].status, AllowlistStatus::Allowed); - 4269
assert_eq!(entries[0].added_by, "config_import"); - 4270
assert!(allowlist_path(&core.shared_data_home()).is_file()); - 4271
- 4272
// Once the file exists, it is authoritative: a config change is not - 4273
// re-imported on the next load. - 4274
gw.allowlist_revoke(&core, "telegram:1"); - 4275
drop(gw); - 4276
let gw2 = GatewayState::load(&core, true); - 4277
let keys: Vec<String> = gw2 - 4278
.allowlist_snapshot() - 4279
.into_iter() - 4280
.map(|e| e.key) - 4281
.collect(); - 4282
assert_eq!(keys, vec!["telegram:2"]); - 4283
} - 4284
- 4285
#[test] - 4286
fn allowlist_route_resolves_the_configured_agent_identity() { - 4287
let (_dir, core) = core_with_config("[memory]\nreflection = false\n"); - 4288
std::fs::write( - 4289
core.cwd().join(".vak/agents.json"), - 4290
serde_json::json!([{ - 4291
"id": "support", - 4292
"revision": 3, - 4293
"name": "Support", - 4294
"character": "orb", - 4295
"personality": "calm", - 4296
"behaviour": "helpful", - 4297
"responsibilities": "support", - 4298
"animation": "off", - 4299
"voice": "default" - 4300
}]) - 4301
.to_string(), - 4302
) - 4303
.unwrap(); - 4304
let gw = GatewayState::load(&core, true); - 4305
gw.allowlist_approve( - 4306
&core, - 4307
"telegram:agent", - 4308
core.cwd().clone(), - 4309
Some("support".into()), - 4310
None, - 4311
None, - 4312
Default::default(), - 4313
None, - 4314
true, - 4315
"test", - 4316
); - 4317
let resolved = gw.core_for_entry(&core, "telegram:agent").unwrap(); - 4318
assert_eq!( - 4319
resolved.agent_identity().map(|agent| agent.id.as_str()), - 4320
Some("support") - 4321
); - 4322
} - 4323
- 4324
#[test] - 4325
fn bot_agent_identity_resolves_when_chat_inherits_bot() { - 4326
let (_dir, core) = core_with_config("[memory]\nreflection = false\n"); - 4327
std::fs::write( - 4328
core.cwd().join(".vak/agents.json"), - 4329
serde_json::json!([{ - 4330
"id": "researcher", - 4331
"revision": 2, - 4332
"name": "Researcher", - 4333
"character": "orb", - 4334
"personality": "curious", - 4335
"behaviour": "thorough", - 4336
"responsibilities": "research", - 4337
"animation": "off", - 4338
"voice": "default" - 4339
}]) - 4340
.to_string(), - 4341
) - 4342
.unwrap(); - 4343
let gw = GatewayState::load(&core, true); - 4344
gw.bot_upsert( - 4345
&core, - 4346
Bot { - 4347
id: "res-bot".into(), - 4348
surface: "telegram".into(), - 4349
label: "Researcher Bot".into(), - 4350
token_env: "BOT_TOKEN__RES".into(), - 4351
agent_id: Some("researcher".into()), - 4352
..Default::default() - 4353
}, - 4354
); - 4355
gw.allowlist_approve( - 4356
&core, - 4357
"telegram:res-chat", - 4358
core.cwd().clone(), - 4359
None, - 4360
None, - 4361
None, - 4362
Default::default(), - 4363
Some("res-bot".into()), - 4364
true, - 4365
"test", - 4366
); - 4367
let resolved = gw.core_for_entry(&core, "telegram:res-chat").unwrap(); - 4368
assert_eq!( - 4369
resolved.agent_identity().map(|agent| agent.id.as_str()), - 4370
Some("researcher") - 4371
); - 4372
- 4373
// Even if legacy entry was stamped with "vak", inherit_bot_policy allows the bot's agent to shine through - 4374
gw.allowlist_patch( - 4375
&core, - 4376
"telegram:res-chat", - 4377
Some(core.cwd().clone()), - 4378
Some(Some("vak".into())), - 4379
None, - 4380
None, - 4381
Default::default(), - 4382
Some(Some("res-bot".into())), - 4383
Some(true), - 4384
None, - 4385
None, - 4386
); - 4387
let resolved_legacy = gw.core_for_entry(&core, "telegram:res-chat").unwrap(); - 4388
assert_eq!( - 4389
resolved_legacy - 4390
.agent_identity() - 4391
.map(|agent| agent.id.as_str()), - 4392
Some("researcher") - 4393
); - 4394
- 4395
// Explicit chat agent override takes precedence - 4396
std::fs::write( - 4397
core.cwd().join(".vak/agents.json"), - 4398
serde_json::json!([ - 4399
{ - 4400
"id": "researcher", - 4401
"revision": 2, - 4402
"name": "Researcher", - 4403
"character": "orb", - 4404
"personality": "curious", - 4405
"behaviour": "thorough", - 4406
"responsibilities": "research", - 4407
"animation": "off", - 4408
"voice": "default" - 4409
}, - 4410
{ - 4411
"id": "support", - 4412
"revision": 2, - 4413
"name": "Support", - 4414
"character": "orb", - 4415
"personality": "helpful", - 4416
"behaviour": "friendly", - 4417
"responsibilities": "support", - 4418
"animation": "off", - 4419
"voice": "default" - 4420
} - 4421
]) - 4422
.to_string(), - 4423
) - 4424
.unwrap(); - 4425
gw.allowlist_patch( - 4426
&core, - 4427
"telegram:res-chat", - 4428
Some(core.cwd().clone()), - 4429
Some(Some("support".into())), - 4430
None, - 4431
None, - 4432
Default::default(), - 4433
Some(Some("res-bot".into())), - 4434
Some(true), - 4435
None, - 4436
None, - 4437
); - 4438
let resolved_override = gw.core_for_entry(&core, "telegram:res-chat").unwrap(); - 4439
assert_eq!( - 4440
resolved_override - 4441
.agent_identity() - 4442
.map(|agent| agent.id.as_str()), - 4443
Some("support") - 4444
); - 4445
- 4446
// Clearing back to None inherits bot's agent again - 4447
gw.allowlist_patch( - 4448
&core, - 4449
"telegram:res-chat", - 4450
Some(core.cwd().clone()), - 4451
Some(None), - 4452
None, - 4453
None, - 4454
Default::default(), - 4455
Some(Some("res-bot".into())), - 4456
Some(true), - 4457
None, - 4458
None, - 4459
); - 4460
let resolved_cleared = gw.core_for_entry(&core, "telegram:res-chat").unwrap(); - 4461
assert_eq!( - 4462
resolved_cleared - 4463
.agent_identity() - 4464
.map(|agent| agent.id.as_str()), - 4465
Some("researcher") - 4466
); - 4467
} - 4468
- 4469
#[test] - 4470
fn paused_agent_route_fails_closed_before_core_creation() { - 4471
let (_dir, core) = core_with_config("[memory]\nreflection = false\n"); - 4472
std::fs::write( - 4473
core.cwd().join(".vak/agents.json"), - 4474
serde_json::json!([{ - 4475
"id": "paused", - 4476
"revision": 1, - 4477
"lifecycle": "paused", - 4478
"name": "Paused", - 4479
"character": "orb", - 4480
"personality": "calm", - 4481
"behaviour": "helpful", - 4482
"responsibilities": "support", - 4483
"animation": "off", - 4484
"voice": "default" - 4485
}]) - 4486
.to_string(), - 4487
) - 4488
.unwrap(); - 4489
let gw = GatewayState::load(&core, true); - 4490
gw.allowlist_approve( - 4491
&core, - 4492
"telegram:paused", - 4493
core.cwd().clone(), - 4494
Some("paused".into()), - 4495
None, - 4496
None, - 4497
Default::default(), - 4498
None, - 4499
true, - 4500
"test", - 4501
); - 4502
assert!(gw.core_for_entry(&core, "telegram:paused").is_err()); - 4503
} - 4504
- 4505
#[test] - 4506
fn allowlist_open_flag_does_not_seed_pending_entries() { - 4507
// An explicit (non-empty) project `chat_allowlist` always overrides - 4508
// whatever a developer's own global config.toml might set, so this - 4509
// stays deterministic regardless of the machine it runs on. - 4510
let (_dir, core) = core_with_config( - 4511
"[gateway]\nchat_allowlist = [\"testonly:1\"]\nchat_allowlist_open = true\n", - 4512
); - 4513
let gw = GatewayState::load(&core, true); - 4514
assert!(gw.chat_allowlist_open()); - 4515
let entries = gw.allowlist_snapshot(); - 4516
assert_eq!(entries.len(), 1); - 4517
assert_eq!(entries[0].key, "testonly:1"); - 4518
assert_eq!(entries[0].status, AllowlistStatus::Allowed); - 4519
assert_eq!(entries[0].added_by, "config_import"); - 4520
} - 4521
- 4522
#[test] - 4523
fn allowlist_approve_deny_revoke_roundtrip() { - 4524
let (_dir, core) = core_with_config("[memory]\nreflection = false\n"); - 4525
let gw = GatewayState::load(&core, true); - 4526
- 4527
// Unknown key: approve creates an allowed entry with the explicit - 4528
// workspace visible in the record (not silently inherited). - 4529
let approved = gw.allowlist_approve( - 4530
&core, - 4531
"telegram:7", - 4532
core.cwd().clone(), - 4533
None, - 4534
Some(AllowlistRoute { - 4535
provider: "anthropic".into(), - 4536
model: "sonnet".into(), - 4537
}), - 4538
None, - 4539
vak_config::ChannelPolicy::default(), - 4540
None, - 4541
true, - 4542
"admin", - 4543
); - 4544
assert_eq!(approved.status, AllowlistStatus::Allowed); - 4545
assert_eq!(approved.workspace.as_deref(), Some(core.cwd().as_path())); - 4546
assert_eq!(approved.agent_id.as_deref(), Some("vak")); - 4547
assert_eq!(approved.route.as_ref().unwrap().provider, "anthropic"); - 4548
- 4549
// Persisted to disk atomically. - 4550
let raw = std::fs::read_to_string(allowlist_path(&core.shared_data_home())).unwrap(); - 4551
assert!(raw.contains("telegram:7")); - 4552
- 4553
// Revoke removes an allowed entry. - 4554
assert!(gw.allowlist_revoke(&core, "telegram:7")); - 4555
assert!(gw.allowlist_get("telegram:7").is_none()); - 4556
// Revoking a nonexistent / non-allowed entry is a no-op failure. - 4557
assert!(!gw.allowlist_revoke(&core, "telegram:7")); - 4558
- 4559
// Deny sticks. - 4560
let denied = gw.allowlist_deny(&core, "telegram:8", "admin"); - 4561
assert_eq!(denied.status, AllowlistStatus::Denied); - 4562
assert!(!gw.allowlist_revoke(&core, "telegram:8")); - 4563
} - 4564
- 4565
#[test] - 4566
fn inbound_resolve_creates_one_pending_entry_not_duplicated() { - 4567
// An explicit project `chat_allowlist` overrides a developer's own - 4568
// global config.toml so the seeded starting state is deterministic. - 4569
let (_dir, core) = core_with_config( - 4570
"[memory]\nreflection = false\n[gateway]\nchat_allowlist = [\"testonly:0\"]\n", - 4571
); - 4572
let gw = GatewayState::load(&core, true); - 4573
- 4574
let first = gw.allowlist_resolve_inbound(&core, "telegram:99", "hello there", None); - 4575
assert!(matches!(first, AllowlistDecision::NewlyPending)); - 4576
let entry = gw.allowlist_get("telegram:99").unwrap(); - 4577
assert_eq!(entry.status, AllowlistStatus::Pending); - 4578
assert_eq!(entry.first_seen_text.as_deref(), Some("hello there")); - 4579
let added_at = entry.added_at.clone(); - 4580
- 4581
// A repeat message on the same pending key does not duplicate or - 4582
// bump added_at. - 4583
let second = gw.allowlist_resolve_inbound(&core, "telegram:99", "hello again", None); - 4584
assert!(matches!(second, AllowlistDecision::StillPending)); - 4585
let entry2 = gw.allowlist_get("telegram:99").unwrap(); - 4586
assert_eq!(entry2.added_at, added_at); - 4587
assert_eq!(entry2.first_seen_text.as_deref(), Some("hello there")); - 4588
let pending_count = gw - 4589
.allowlist_snapshot() - 4590
.iter() - 4591
.filter(|e| e.status == AllowlistStatus::Pending) - 4592
.count(); - 4593
assert_eq!(pending_count, 1, "no duplicate pending entry created"); - 4594
} - 4595
- 4596
/// Multi-bot-per-channel (docs/design/34 Phase 5 follow-up): a chat - 4597
/// already trusted under its legacy two-part key must not force a - 4598
/// fresh approval the moment its bridge starts sending a bot id — the - 4599
/// same physical chat, now seen through a bot, inherits the existing - 4600
/// grant. And a *second* bot joining that same physical chat gets its - 4601
/// own independent entry too, inherited from the same legacy row — - 4602
/// this is the actual "a channel can have multiple independent bots" - 4603
/// capability, not just the UI to configure it. - 4604
#[test] - 4605
fn bot_scoped_key_inherits_approval_from_already_allowed_legacy_key() { - 4606
let (_dir, core) = core_with_config( - 4607
"[memory]\nreflection = false\n[gateway]\nchat_allowlist = [\"telegram:8846301562\"]\n", - 4608
); - 4609
let gw = GatewayState::load(&core, true); - 4610
let legacy = gw.allowlist_get("telegram:8846301562").unwrap(); - 4611
assert_eq!(legacy.status, AllowlistStatus::Allowed); - 4612
- 4613
// Seed a stale binding at the legacy key, as if a session had - 4614
// actually been dispatched there before bots existed — this is - 4615
// what must go away once a bot-scoped sibling takes over, so the - 4616
// Chats list doesn't show a dead duplicate. - 4617
gw.bind( - 4618
&core, - 4619
"telegram:8846301562".into(), - 4620
"fake-session".into(), - 4621
"rev".into(), - 4622
); - 4623
- 4624
let decision = - 4625
gw.allowlist_resolve_inbound(&core, "telegram:8846301562:VakBot", "hi", Some("VakBot")); - 4626
assert!(matches!(decision, AllowlistDecision::Allowed)); - 4627
let inherited = gw.allowlist_get("telegram:8846301562:VakBot").unwrap(); - 4628
assert_eq!(inherited.status, AllowlistStatus::Allowed); - 4629
assert_eq!(inherited.bot_id.as_deref(), Some("VakBot")); - 4630
assert_eq!(inherited.workspace, legacy.workspace); - 4631
// The legacy row's *allowlist entry* is untouched — it stays - 4632
// around as the ancestor a third bot could still inherit from - 4633
// later. - 4634
assert_eq!( - 4635
gw.allowlist_get("telegram:8846301562").unwrap().status, - 4636
AllowlistStatus::Allowed - 4637
); - 4638
// Its *binding* is gone, though — that's the actual duplicate-row - 4639
// fix: nothing will ever dispatch to the legacy key again. - 4640
assert!( - 4641
gw.bindings_snapshot() - 4642
.iter() - 4643
.all(|(k, _)| k != "telegram:8846301562"), - 4644
"legacy binding must be unbound once a bot-scoped sibling exists" - 4645
); - 4646
- 4647
// A second, independent bot on the same physical chat inherits - 4648
// too, and gets a genuinely separate entry from the first bot's. - 4649
let decision2 = gw.allowlist_resolve_inbound( - 4650
&core, - 4651
"telegram:8846301562:Vakyartha", - 4652
"hi", - 4653
Some("Vakyartha"), - 4654
); - 4655
assert!(matches!(decision2, AllowlistDecision::Allowed)); - 4656
let inherited2 = gw.allowlist_get("telegram:8846301562:Vakyartha").unwrap(); - 4657
assert_eq!(inherited2.bot_id.as_deref(), Some("Vakyartha")); - 4658
assert_ne!( - 4659
gw.allowlist_get("telegram:8846301562:VakBot") - 4660
.unwrap() - 4661
.bot_id, - 4662
inherited2.bot_id, - 4663
"each bot must get its own entry, not share one" - 4664
); - 4665
} - 4666
- 4667
/// No legacy approval to inherit means the normal pending-review path, - 4668
/// same as any other never-seen chat. - 4669
#[test] - 4670
fn bot_scoped_key_starts_pending_when_no_legacy_approval_exists() { - 4671
let (_dir, core) = core_with_config("[memory]\nreflection = false\n"); - 4672
let gw = GatewayState::load(&core, true); - 4673
let decision = - 4674
gw.allowlist_resolve_inbound(&core, "telegram:555:NewBot", "hi", Some("NewBot")); - 4675
assert!(matches!(decision, AllowlistDecision::NewlyPending)); - 4676
let entry = gw.allowlist_get("telegram:555:NewBot").unwrap(); - 4677
assert_eq!(entry.status, AllowlistStatus::Pending); - 4678
} - 4679
- 4680
#[test] - 4681
fn legacy_key_for_strips_bot_id_and_is_none_for_shorter_keys() { - 4682
assert_eq!( - 4683
legacy_key_for("telegram:123:VakBot"), - 4684
Some("telegram:123".to_string()) - 4685
); - 4686
assert_eq!(legacy_key_for("telegram:123"), None); - 4687
assert_eq!(legacy_key_for("telegram"), None); - 4688
} - 4689
- 4690
#[test] - 4691
fn inbound_resolve_dispatches_allowed_and_denied_keys() { - 4692
let (_dir, core) = core_with_config( - 4693
"[memory]\nreflection = false\n[gateway]\nchat_allowlist = [\"testonly:0\"]\n", - 4694
); - 4695
let gw = GatewayState::load(&core, true); - 4696
- 4697
// Allowed key dispatches normally. - 4698
gw.allowlist_approve( - 4699
&core, - 4700
"telegram:100", - 4701
core.cwd().clone(), - 4702
None, - 4703
None, - 4704
None, - 4705
vak_config::ChannelPolicy::default(), - 4706
None, - 4707
true, - 4708
"admin", - 4709
); - 4710
assert!(matches!( - 4711
gw.allowlist_resolve_inbound(&core, "telegram:100", "hi", None), - 4712
AllowlistDecision::Allowed - 4713
)); - 4714
- 4715
// Denied key stays rejected. - 4716
gw.allowlist_deny(&core, "telegram:101", "admin"); - 4717
assert!(matches!( - 4718
gw.allowlist_resolve_inbound(&core, "telegram:101", "hi", None), - 4719
AllowlistDecision::Denied - 4720
)); - 4721
} - 4722
- 4723
#[test] - 4724
fn first_seen_text_is_truncated() { - 4725
let (_dir, core) = core_with_config("[memory]\nreflection = false\n"); - 4726
let gw = GatewayState::load(&core, true); - 4727
let long = "x".repeat(FIRST_SEEN_TEXT_MAX_CHARS + 50); - 4728
gw.allowlist_resolve_inbound(&core, "telegram:200", &long, None); - 4729
let entry = gw.allowlist_get("telegram:200").unwrap(); - 4730
assert_eq!( - 4731
entry.first_seen_text.unwrap().chars().count(), - 4732
FIRST_SEEN_TEXT_MAX_CHARS - 4733
); - 4734
} - 4735
- 4736
#[tokio::test] - 4737
async fn permission_revoke_denies_forwarded_gates_for_session() { - 4738
let (_dir, core) = core_with_config("[memory]\nreflection = false\n"); - 4739
let gw = GatewayState::load(&core, true); - 4740
let mut receivers = Vec::new(); - 4741
for id in ["gate-a", "gate-b", "other"] { - 4742
let rx = gw.register_gate(id, if id == "other" { "s2" } else { "s1" }); - 4743
receivers.push((id, rx)); - 4744
} - 4745
assert_eq!(gw.deny_pending_for_session("s1"), 2); - 4746
let (_, first) = receivers.remove(0); - 4747
let (_, second) = receivers.remove(0); - 4748
assert!(!first.await.unwrap()); - 4749
assert!(!second.await.unwrap()); - 4750
assert!(gw.resolve_gate(true, Some("gate-a")).is_err()); - 4751
assert!(gw.resolve_gate(true, Some("gate-b")).is_err()); - 4752
assert_eq!( - 4753
gw.resolve_gate(false, Some("other")).unwrap().session_id, - 4754
"s2" - 4755
); - 4756
} - 4757
} - 4758
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.