hajimi-claw 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,747 @@
1
+ use std::sync::Arc;
2
+
3
+ use async_trait::async_trait;
4
+ use chrono::Utc;
5
+ use hajimi_claw_agent::AgentRuntime;
6
+ use hajimi_claw_llm::{list_models, test_provider};
7
+ use hajimi_claw_policy::PolicyEngine;
8
+ use hajimi_claw_store::Store;
9
+ use hajimi_claw_types::{
10
+ ClawError, ClawResult, OnboardingSession, OnboardingStep, ProviderConfig, ProviderDraft,
11
+ ProviderKind, ProviderRecord,
12
+ };
13
+ use reqwest::Client;
14
+ use serde::{Deserialize, Serialize};
15
+
16
+ #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17
+ pub enum GatewayCommand {
18
+ Ask(String),
19
+ ShellOpen(Option<String>),
20
+ ShellExec(String),
21
+ ShellClose,
22
+ Status,
23
+ Approve(String),
24
+ ElevatedStart { minutes: i64, reason: String },
25
+ ElevatedStop,
26
+ Cancel(String),
27
+ Onboard,
28
+ OnboardCancel,
29
+ ProviderList,
30
+ ProviderUse(String),
31
+ ProviderBind(String),
32
+ ProviderCurrent,
33
+ ProviderTest(Option<String>),
34
+ ProviderModels(Option<String>),
35
+ Help,
36
+ Unknown(String),
37
+ }
38
+
39
+ #[derive(Debug, Clone, Serialize, Deserialize)]
40
+ pub struct GatewayRequest {
41
+ pub actor_user_id: i64,
42
+ pub actor_chat_id: i64,
43
+ pub raw_text: String,
44
+ pub command: GatewayCommand,
45
+ pub current_session_id: Option<String>,
46
+ }
47
+
48
+ #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
49
+ pub enum SessionDirective {
50
+ Keep,
51
+ Set(String),
52
+ Clear,
53
+ }
54
+
55
+ #[derive(Debug, Clone, Serialize, Deserialize)]
56
+ pub struct GatewayResponse {
57
+ pub text: String,
58
+ pub session: SessionDirective,
59
+ pub keyboard: Option<InlineKeyboard>,
60
+ }
61
+
62
+ #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63
+ pub struct InlineKeyboard {
64
+ pub rows: Vec<Vec<InlineButton>>,
65
+ }
66
+
67
+ #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
68
+ pub struct InlineButton {
69
+ pub text: String,
70
+ pub data: String,
71
+ }
72
+
73
+ #[async_trait]
74
+ pub trait Gateway: Send + Sync {
75
+ async fn handle(&self, request: GatewayRequest) -> ClawResult<GatewayResponse>;
76
+ }
77
+
78
+ pub struct InProcessGateway {
79
+ runtime: Arc<AgentRuntime>,
80
+ policy: Arc<PolicyEngine>,
81
+ store: Arc<Store>,
82
+ client: Client,
83
+ }
84
+
85
+ impl InProcessGateway {
86
+ pub fn new(runtime: Arc<AgentRuntime>, policy: Arc<PolicyEngine>, store: Arc<Store>) -> Self {
87
+ Self {
88
+ runtime,
89
+ policy,
90
+ store,
91
+ client: Client::new(),
92
+ }
93
+ }
94
+ }
95
+
96
+ #[async_trait]
97
+ impl Gateway for InProcessGateway {
98
+ async fn handle(&self, request: GatewayRequest) -> ClawResult<GatewayResponse> {
99
+ if !self
100
+ .policy
101
+ .authorize_telegram_actor(request.actor_user_id, request.actor_chat_id)
102
+ {
103
+ return Err(ClawError::AccessDenied(
104
+ "telegram actor is not authorized".into(),
105
+ ));
106
+ }
107
+
108
+ if matches!(request.command, GatewayCommand::OnboardCancel) {
109
+ self.store
110
+ .clear_onboarding_session(request.actor_chat_id, request.actor_user_id)
111
+ .map_err(store_error)?;
112
+ return Ok(text_response("onboarding cancelled"));
113
+ }
114
+
115
+ if let Some(session) = self
116
+ .store
117
+ .load_onboarding_session(request.actor_chat_id, request.actor_user_id)
118
+ .map_err(store_error)?
119
+ {
120
+ if !request.raw_text.trim_start().starts_with('/') {
121
+ return self.continue_onboarding(session, request).await;
122
+ }
123
+ }
124
+
125
+ match request.command {
126
+ GatewayCommand::Ask(prompt) => {
127
+ let provider_id = self
128
+ .store
129
+ .resolve_provider_for_chat(request.actor_chat_id)
130
+ .map_err(store_error)?
131
+ .map(|record| record.config.id);
132
+ Ok(GatewayResponse {
133
+ text: self
134
+ .runtime
135
+ .ask_with_provider(&prompt, None, provider_id)
136
+ .await?,
137
+ session: SessionDirective::Keep,
138
+ keyboard: None,
139
+ })
140
+ }
141
+ GatewayCommand::ShellOpen(name) => {
142
+ let reply = self.runtime.shell_open(name, None).await?;
143
+ Ok(GatewayResponse {
144
+ text: reply.message,
145
+ session: SessionDirective::Set(reply.session_id),
146
+ keyboard: None,
147
+ })
148
+ }
149
+ GatewayCommand::ShellExec(command) => {
150
+ let session_id = request.current_session_id.ok_or_else(|| {
151
+ ClawError::InvalidRequest("no active session, use /shell open first".into())
152
+ })?;
153
+ Ok(GatewayResponse {
154
+ text: self.runtime.shell_exec(&session_id, &command).await?,
155
+ session: SessionDirective::Keep,
156
+ keyboard: None,
157
+ })
158
+ }
159
+ GatewayCommand::ShellClose => {
160
+ let session_id = request.current_session_id.ok_or_else(|| {
161
+ ClawError::InvalidRequest("no active session, use /shell open first".into())
162
+ })?;
163
+ Ok(GatewayResponse {
164
+ text: self.runtime.shell_close(&session_id).await?,
165
+ session: SessionDirective::Clear,
166
+ keyboard: None,
167
+ })
168
+ }
169
+ GatewayCommand::Status => Ok(text_response(&self.runtime.status()?)),
170
+ GatewayCommand::Approve(request_id) => {
171
+ Ok(text_response(&self.runtime.approve(&request_id)?))
172
+ }
173
+ GatewayCommand::ElevatedStart { minutes, reason } => Ok(text_response(
174
+ &self.runtime.request_elevated(minutes, reason),
175
+ )),
176
+ GatewayCommand::ElevatedStop => Ok(text_response(&self.runtime.stop_elevated())),
177
+ GatewayCommand::Cancel(task_id) => Ok(text_response(&format!(
178
+ "cancel is not implemented yet for task {task_id}"
179
+ ))),
180
+ GatewayCommand::Onboard => self.start_onboarding(request).await,
181
+ GatewayCommand::ProviderList => self.provider_list().await,
182
+ GatewayCommand::ProviderUse(provider_id) => self.provider_use(&provider_id).await,
183
+ GatewayCommand::ProviderBind(provider_id) => {
184
+ self.provider_bind(request.actor_chat_id, &provider_id)
185
+ .await
186
+ }
187
+ GatewayCommand::ProviderCurrent => self.provider_current(request.actor_chat_id).await,
188
+ GatewayCommand::ProviderTest(provider_id) => {
189
+ self.provider_test(request.actor_chat_id, provider_id.as_deref())
190
+ .await
191
+ }
192
+ GatewayCommand::ProviderModels(provider_id) => {
193
+ self.provider_models(request.actor_chat_id, provider_id.as_deref())
194
+ .await
195
+ }
196
+ GatewayCommand::Help => Ok(text_response(&help_text())),
197
+ GatewayCommand::Unknown(raw) => Ok(text_response(&format!(
198
+ "unrecognized command: {raw}\n\n{}",
199
+ help_text()
200
+ ))),
201
+ GatewayCommand::OnboardCancel => Ok(text_response("onboarding cancelled")),
202
+ }
203
+ }
204
+ }
205
+
206
+ impl InProcessGateway {
207
+ async fn start_onboarding(&self, request: GatewayRequest) -> ClawResult<GatewayResponse> {
208
+ let session = OnboardingSession {
209
+ user_id: request.actor_user_id,
210
+ chat_id: request.actor_chat_id,
211
+ step: OnboardingStep::ProviderLabel,
212
+ draft: ProviderDraft::default(),
213
+ updated_at: Utc::now(),
214
+ };
215
+ self.store
216
+ .save_onboarding_session(&session)
217
+ .map_err(store_error)?;
218
+ Ok(text_response_with_keyboard(
219
+ "hajimi onboard started.\nStep 1/5: send a short provider label, for example `OpenAI` or `Moonshot`.\nSend `/onboard cancel` to stop.",
220
+ Some(cancel_keyboard()),
221
+ ))
222
+ }
223
+
224
+ async fn continue_onboarding(
225
+ &self,
226
+ mut session: OnboardingSession,
227
+ request: GatewayRequest,
228
+ ) -> ClawResult<GatewayResponse> {
229
+ let input = request.raw_text.trim();
230
+ match session.step {
231
+ OnboardingStep::ProviderLabel => {
232
+ session.draft.label = Some(input.to_string());
233
+ session.step = OnboardingStep::ProviderKind;
234
+ session.updated_at = Utc::now();
235
+ self.store
236
+ .save_onboarding_session(&session)
237
+ .map_err(store_error)?;
238
+ Ok(text_response_with_keyboard(
239
+ "Step 2/5: choose provider kind below or send it manually: `openai-compatible` or `custom-chat-completions`.",
240
+ Some(provider_kind_keyboard()),
241
+ ))
242
+ }
243
+ OnboardingStep::ProviderKind => {
244
+ session.draft.kind = Some(parse_provider_kind(input)?);
245
+ session.step = OnboardingStep::ProviderBaseUrl;
246
+ session.updated_at = Utc::now();
247
+ self.store
248
+ .save_onboarding_session(&session)
249
+ .map_err(store_error)?;
250
+ Ok(text_response(
251
+ "Step 3/5: send the API base URL, for example `https://api.openai.com/v1`.",
252
+ ))
253
+ }
254
+ OnboardingStep::ProviderBaseUrl => {
255
+ session.draft.base_url = Some(normalize_base_url(input));
256
+ session.step = OnboardingStep::ProviderApiKey;
257
+ session.updated_at = Utc::now();
258
+ self.store
259
+ .save_onboarding_session(&session)
260
+ .map_err(store_error)?;
261
+ Ok(text_response(
262
+ "Step 4/5: send the API key. It will be encrypted before being stored in SQLite.",
263
+ ))
264
+ }
265
+ OnboardingStep::ProviderApiKey => {
266
+ session.draft.api_key = Some(input.to_string());
267
+ session.step = OnboardingStep::ProviderModel;
268
+ session.updated_at = Utc::now();
269
+ self.store
270
+ .save_onboarding_session(&session)
271
+ .map_err(store_error)?;
272
+ Ok(text_response(
273
+ "Step 5/5: send the default model name, for example `gpt-4.1-mini`.",
274
+ ))
275
+ }
276
+ OnboardingStep::ProviderModel => {
277
+ session.draft.model = Some(input.to_string());
278
+ let record = finalize_provider(session.draft.clone())?;
279
+ let make_default = self
280
+ .store
281
+ .get_default_provider()
282
+ .map_err(store_error)?
283
+ .is_none();
284
+ self.store
285
+ .upsert_provider(&ProviderRecord {
286
+ config: record.clone(),
287
+ is_default: make_default,
288
+ })
289
+ .map_err(store_error)?;
290
+ self.store
291
+ .bind_provider_to_chat(request.actor_chat_id, &record.id)
292
+ .map_err(store_error)?;
293
+ self.store
294
+ .clear_onboarding_session(request.actor_chat_id, request.actor_user_id)
295
+ .map_err(store_error)?;
296
+
297
+ let health = test_provider(&self.client, &record).await?;
298
+ Ok(text_response(&format!(
299
+ "onboarding complete.\nprovider=`{}` id=`{}`{}\nchat_binding=enabled\nhealth={}\n{}",
300
+ record.label,
301
+ record.id,
302
+ if make_default { " default=yes" } else { "" },
303
+ if health.ok { "ok" } else { "failed" },
304
+ health.message
305
+ )))
306
+ }
307
+ OnboardingStep::Completed => Ok(text_response("onboarding already completed")),
308
+ }
309
+ }
310
+
311
+ async fn provider_list(&self) -> ClawResult<GatewayResponse> {
312
+ let providers = self.store.list_providers().map_err(store_error)?;
313
+ if providers.is_empty() {
314
+ return Ok(text_response(
315
+ "no providers configured. Use `/onboard` to add one.",
316
+ ));
317
+ }
318
+ let text = providers
319
+ .into_iter()
320
+ .map(|provider| {
321
+ format!(
322
+ "`{}` {} kind={} model={}{}",
323
+ provider.config.id,
324
+ provider.config.label,
325
+ provider.config.kind.as_str(),
326
+ provider.config.model,
327
+ if provider.is_default { " default" } else { "" }
328
+ )
329
+ })
330
+ .collect::<Vec<_>>()
331
+ .join("\n");
332
+ Ok(text_response(&text))
333
+ }
334
+
335
+ async fn provider_use(&self, provider_id: &str) -> ClawResult<GatewayResponse> {
336
+ ensure_provider_exists(&self.store, provider_id)?;
337
+ self.store
338
+ .set_default_provider(provider_id)
339
+ .map_err(store_error)?;
340
+ Ok(text_response(&format!(
341
+ "default provider set to `{provider_id}`"
342
+ )))
343
+ }
344
+
345
+ async fn provider_bind(&self, chat_id: i64, provider_id: &str) -> ClawResult<GatewayResponse> {
346
+ ensure_provider_exists(&self.store, provider_id)?;
347
+ self.store
348
+ .bind_provider_to_chat(chat_id, provider_id)
349
+ .map_err(store_error)?;
350
+ Ok(text_response(&format!(
351
+ "chat `{chat_id}` now uses provider `{provider_id}`"
352
+ )))
353
+ }
354
+
355
+ async fn provider_current(&self, chat_id: i64) -> ClawResult<GatewayResponse> {
356
+ let bound = self
357
+ .store
358
+ .get_bound_provider_id(chat_id)
359
+ .map_err(store_error)?;
360
+ let resolved = self
361
+ .store
362
+ .resolve_provider_for_chat(chat_id)
363
+ .map_err(store_error)?;
364
+ match resolved {
365
+ Some(provider) => Ok(text_response(&format!(
366
+ "current provider for chat `{chat_id}`: `{}` ({}){}\ndefault={}",
367
+ provider.config.id,
368
+ provider.config.label,
369
+ if bound.is_some() {
370
+ " bound=yes"
371
+ } else {
372
+ " bound=no"
373
+ },
374
+ provider.is_default
375
+ ))),
376
+ None => Ok(text_response("no provider configured")),
377
+ }
378
+ }
379
+
380
+ async fn provider_test(
381
+ &self,
382
+ chat_id: i64,
383
+ provider_id: Option<&str>,
384
+ ) -> ClawResult<GatewayResponse> {
385
+ let provider = if let Some(provider_id) = provider_id {
386
+ self.store
387
+ .get_provider(provider_id)
388
+ .map_err(store_error)?
389
+ .ok_or_else(|| ClawError::NotFound(format!("provider not found: {provider_id}")))?
390
+ } else {
391
+ self.store
392
+ .resolve_provider_for_chat(chat_id)
393
+ .map_err(store_error)?
394
+ .ok_or_else(|| ClawError::NotFound("no provider configured".into()))?
395
+ };
396
+ let health = test_provider(&self.client, &provider.config).await?;
397
+ let models = if health.suggested_models.is_empty() {
398
+ String::from("none")
399
+ } else {
400
+ health.suggested_models.join(", ")
401
+ };
402
+ Ok(text_response(&format!(
403
+ "provider `{}` test={}\n{}\nmodels={}",
404
+ provider.config.id,
405
+ if health.ok { "ok" } else { "failed" },
406
+ health.message,
407
+ models
408
+ )))
409
+ }
410
+
411
+ async fn provider_models(
412
+ &self,
413
+ chat_id: i64,
414
+ provider_id: Option<&str>,
415
+ ) -> ClawResult<GatewayResponse> {
416
+ let provider = if let Some(provider_id) = provider_id {
417
+ self.store
418
+ .get_provider(provider_id)
419
+ .map_err(store_error)?
420
+ .ok_or_else(|| ClawError::NotFound(format!("provider not found: {provider_id}")))?
421
+ } else {
422
+ self.store
423
+ .resolve_provider_for_chat(chat_id)
424
+ .map_err(store_error)?
425
+ .ok_or_else(|| ClawError::NotFound("no provider configured".into()))?
426
+ };
427
+ let models = list_models(&self.client, &provider.config).await?;
428
+ if models.is_empty() {
429
+ return Ok(text_response("provider returned no models"));
430
+ }
431
+ Ok(text_response(&format!(
432
+ "models for `{}`:\n{}",
433
+ provider.config.id,
434
+ models.join("\n")
435
+ )))
436
+ }
437
+ }
438
+
439
+ pub fn parse_gateway_command(text: &str) -> GatewayCommand {
440
+ let trimmed = text.trim();
441
+ if let Some(rest) = trimmed.strip_prefix("/ask ") {
442
+ return GatewayCommand::Ask(rest.trim().into());
443
+ }
444
+ if trimmed == "/status" {
445
+ return GatewayCommand::Status;
446
+ }
447
+ if trimmed == "/onboard" {
448
+ return GatewayCommand::Onboard;
449
+ }
450
+ if trimmed == "/onboard cancel" {
451
+ return GatewayCommand::OnboardCancel;
452
+ }
453
+ if trimmed == "/provider list" {
454
+ return GatewayCommand::ProviderList;
455
+ }
456
+ if trimmed == "/provider current" {
457
+ return GatewayCommand::ProviderCurrent;
458
+ }
459
+ if let Some(rest) = trimmed.strip_prefix("/provider models") {
460
+ let value = rest.trim();
461
+ return GatewayCommand::ProviderModels((!value.is_empty()).then(|| value.to_string()));
462
+ }
463
+ if let Some(rest) = trimmed.strip_prefix("/provider use ") {
464
+ return GatewayCommand::ProviderUse(rest.trim().into());
465
+ }
466
+ if let Some(rest) = trimmed.strip_prefix("/provider bind ") {
467
+ return GatewayCommand::ProviderBind(rest.trim().into());
468
+ }
469
+ if let Some(rest) = trimmed.strip_prefix("/provider test") {
470
+ let value = rest.trim();
471
+ return GatewayCommand::ProviderTest((!value.is_empty()).then(|| value.to_string()));
472
+ }
473
+ if let Some(rest) = trimmed.strip_prefix("/approve ") {
474
+ return GatewayCommand::Approve(rest.trim().into());
475
+ }
476
+ if trimmed == "/elevated stop" {
477
+ return GatewayCommand::ElevatedStop;
478
+ }
479
+ if let Some(rest) = trimmed.strip_prefix("/elevated start ") {
480
+ let mut parts = rest.trim().splitn(2, ' ');
481
+ let minutes = parts
482
+ .next()
483
+ .and_then(|value| value.parse::<i64>().ok())
484
+ .unwrap_or(10);
485
+ let reason = parts.next().unwrap_or("manual request").trim().to_string();
486
+ return GatewayCommand::ElevatedStart { minutes, reason };
487
+ }
488
+ if trimmed == "/shell close" {
489
+ return GatewayCommand::ShellClose;
490
+ }
491
+ if let Some(rest) = trimmed.strip_prefix("/shell open") {
492
+ let name = rest.trim();
493
+ return GatewayCommand::ShellOpen((!name.is_empty()).then(|| name.to_string()));
494
+ }
495
+ if let Some(rest) = trimmed.strip_prefix("/shell exec ") {
496
+ return GatewayCommand::ShellExec(rest.trim().into());
497
+ }
498
+ if let Some(rest) = trimmed.strip_prefix("/cancel ") {
499
+ return GatewayCommand::Cancel(rest.trim().into());
500
+ }
501
+ if trimmed == "/help" || trimmed == "/start" {
502
+ return GatewayCommand::Help;
503
+ }
504
+ GatewayCommand::Unknown(trimmed.into())
505
+ }
506
+
507
+ pub fn help_text() -> String {
508
+ [
509
+ "/ask <text>",
510
+ "/onboard",
511
+ "/onboard cancel",
512
+ "/provider list",
513
+ "/provider current",
514
+ "/provider use <id>",
515
+ "/provider bind <id>",
516
+ "/provider test [id]",
517
+ "/provider models [id]",
518
+ "/shell open [name]",
519
+ "/shell exec <cmd>",
520
+ "/shell close",
521
+ "/status",
522
+ "/approve <request-id>",
523
+ "/elevated start <minutes> <reason>",
524
+ "/elevated stop",
525
+ "/cancel <task-id>",
526
+ ]
527
+ .join("\n")
528
+ }
529
+
530
+ fn ensure_provider_exists(store: &Store, provider_id: &str) -> ClawResult<()> {
531
+ if store
532
+ .get_provider(provider_id)
533
+ .map_err(store_error)?
534
+ .is_none()
535
+ {
536
+ return Err(ClawError::NotFound(format!(
537
+ "provider not found: {provider_id}"
538
+ )));
539
+ }
540
+ Ok(())
541
+ }
542
+
543
+ fn finalize_provider(draft: ProviderDraft) -> ClawResult<ProviderConfig> {
544
+ let label = draft
545
+ .label
546
+ .ok_or_else(|| ClawError::InvalidRequest("missing provider label".into()))?;
547
+ let kind = draft
548
+ .kind
549
+ .ok_or_else(|| ClawError::InvalidRequest("missing provider kind".into()))?;
550
+ let base_url = draft
551
+ .base_url
552
+ .ok_or_else(|| ClawError::InvalidRequest("missing provider base_url".into()))?;
553
+ let api_key = draft
554
+ .api_key
555
+ .ok_or_else(|| ClawError::InvalidRequest("missing provider api_key".into()))?;
556
+ let model = draft
557
+ .model
558
+ .ok_or_else(|| ClawError::InvalidRequest("missing provider model".into()))?;
559
+
560
+ Ok(ProviderConfig {
561
+ id: slugify(&label),
562
+ label,
563
+ kind,
564
+ base_url,
565
+ api_key,
566
+ model,
567
+ enabled: true,
568
+ extra_headers: vec![],
569
+ created_at: Utc::now(),
570
+ })
571
+ }
572
+
573
+ fn parse_provider_kind(raw: &str) -> ClawResult<ProviderKind> {
574
+ match raw.trim().to_ascii_lowercase().as_str() {
575
+ "openai-compatible" | "openai" => Ok(ProviderKind::OpenAiCompatible),
576
+ "custom-chat-completions" | "custom" => Ok(ProviderKind::CustomChatCompletions),
577
+ _ => Err(ClawError::InvalidRequest(
578
+ "provider kind must be `openai-compatible` or `custom-chat-completions`".into(),
579
+ )),
580
+ }
581
+ }
582
+
583
+ fn normalize_base_url(raw: &str) -> String {
584
+ raw.trim().trim_end_matches('/').to_string()
585
+ }
586
+
587
+ fn slugify(value: &str) -> String {
588
+ let mut slug = value
589
+ .chars()
590
+ .map(|ch| {
591
+ if ch.is_ascii_alphanumeric() {
592
+ ch.to_ascii_lowercase()
593
+ } else {
594
+ '-'
595
+ }
596
+ })
597
+ .collect::<String>();
598
+ while slug.contains("--") {
599
+ slug = slug.replace("--", "-");
600
+ }
601
+ slug.trim_matches('-').to_string()
602
+ }
603
+
604
+ fn store_error(err: anyhow::Error) -> ClawError {
605
+ ClawError::Backend(err.to_string())
606
+ }
607
+
608
+ fn text_response(text: &str) -> GatewayResponse {
609
+ GatewayResponse {
610
+ text: text.to_string(),
611
+ session: SessionDirective::Keep,
612
+ keyboard: None,
613
+ }
614
+ }
615
+
616
+ fn text_response_with_keyboard(text: &str, keyboard: Option<InlineKeyboard>) -> GatewayResponse {
617
+ GatewayResponse {
618
+ text: text.to_string(),
619
+ session: SessionDirective::Keep,
620
+ keyboard,
621
+ }
622
+ }
623
+
624
+ fn provider_kind_keyboard() -> InlineKeyboard {
625
+ InlineKeyboard {
626
+ rows: vec![
627
+ vec![
628
+ InlineButton {
629
+ text: "OpenAI-compatible".into(),
630
+ data: "openai-compatible".into(),
631
+ },
632
+ InlineButton {
633
+ text: "Custom Chat".into(),
634
+ data: "custom-chat-completions".into(),
635
+ },
636
+ ],
637
+ vec![InlineButton {
638
+ text: "Cancel onboarding".into(),
639
+ data: "/onboard cancel".into(),
640
+ }],
641
+ ],
642
+ }
643
+ }
644
+
645
+ fn cancel_keyboard() -> InlineKeyboard {
646
+ InlineKeyboard {
647
+ rows: vec![vec![InlineButton {
648
+ text: "Cancel onboarding".into(),
649
+ data: "/onboard cancel".into(),
650
+ }]],
651
+ }
652
+ }
653
+
654
+ #[cfg(test)]
655
+ mod tests {
656
+ use std::sync::Arc;
657
+
658
+ use anyhow::Result;
659
+ use hajimi_claw_agent::AgentRuntime;
660
+ use hajimi_claw_exec::{LocalExecutor, PlatformMode};
661
+ use hajimi_claw_policy::PolicyEngine;
662
+ use hajimi_claw_store::Store;
663
+ use hajimi_claw_tools::ToolRegistry;
664
+ use tempfile::tempdir;
665
+
666
+ use super::{
667
+ Gateway, GatewayCommand, GatewayRequest, InProcessGateway, SessionDirective,
668
+ parse_gateway_command,
669
+ };
670
+
671
+ #[test]
672
+ fn parses_provider_test_command() {
673
+ assert_eq!(
674
+ parse_gateway_command("/provider test moonshot"),
675
+ GatewayCommand::ProviderTest(Some("moonshot".into()))
676
+ );
677
+ }
678
+
679
+ #[tokio::test]
680
+ async fn gateway_opens_session_and_sets_channel_state() -> Result<()> {
681
+ let dir = tempdir()?;
682
+ let mut config = hajimi_claw_policy::PolicyConfig::default();
683
+ config.allowed_workdirs = vec![dir.path().to_path_buf(), std::env::current_dir()?];
684
+ config.admin_user_id = 1;
685
+ config.admin_chat_id = 2;
686
+ let policy = Arc::new(PolicyEngine::new(config));
687
+ let executor = Arc::new(LocalExecutor::new(
688
+ policy.clone(),
689
+ PlatformMode::WindowsSafe,
690
+ ));
691
+ let tools = Arc::new(ToolRegistry::default(executor, policy.clone()));
692
+ let store = Arc::new(Store::open_in_memory()?);
693
+ let runtime = Arc::new(AgentRuntime::for_tests(
694
+ tools,
695
+ store.clone(),
696
+ policy.clone(),
697
+ ));
698
+ let gateway = InProcessGateway::new(runtime, policy, store);
699
+
700
+ let response = gateway
701
+ .handle(GatewayRequest {
702
+ actor_user_id: 1,
703
+ actor_chat_id: 2,
704
+ raw_text: "/shell open ops".into(),
705
+ command: GatewayCommand::ShellOpen(Some("ops".into())),
706
+ current_session_id: None,
707
+ })
708
+ .await?;
709
+ assert!(matches!(response.session, SessionDirective::Set(_)));
710
+ Ok(())
711
+ }
712
+
713
+ #[tokio::test]
714
+ async fn onboarding_session_starts() -> Result<()> {
715
+ let dir = tempdir()?;
716
+ let mut config = hajimi_claw_policy::PolicyConfig::default();
717
+ config.allowed_workdirs = vec![dir.path().to_path_buf(), std::env::current_dir()?];
718
+ config.admin_user_id = 1;
719
+ config.admin_chat_id = 2;
720
+ let policy = Arc::new(PolicyEngine::new(config));
721
+ let executor = Arc::new(LocalExecutor::new(
722
+ policy.clone(),
723
+ PlatformMode::WindowsSafe,
724
+ ));
725
+ let tools = Arc::new(ToolRegistry::default(executor, policy.clone()));
726
+ let store = Arc::new(Store::open_in_memory()?);
727
+ let runtime = Arc::new(AgentRuntime::for_tests(
728
+ tools,
729
+ store.clone(),
730
+ policy.clone(),
731
+ ));
732
+ let gateway = InProcessGateway::new(runtime, policy, store.clone());
733
+
734
+ let response = gateway
735
+ .handle(GatewayRequest {
736
+ actor_user_id: 1,
737
+ actor_chat_id: 2,
738
+ raw_text: "/onboard".into(),
739
+ command: GatewayCommand::Onboard,
740
+ current_session_id: None,
741
+ })
742
+ .await?;
743
+ assert!(response.text.contains("Step 1/5"));
744
+ assert!(store.load_onboarding_session(2, 1)?.is_some());
745
+ Ok(())
746
+ }
747
+ }