1use serde::{Deserialize, Serialize};
10
11#[cfg(feature = "backend")]
12pub mod crypto;
13#[cfg(feature = "backend")]
14pub mod db;
15pub mod deploy;
16pub mod oauth;
17#[cfg(feature = "backend")]
18pub mod service;
19
20pub use opensession_core::trace::{
22 Agent, Content, ContentBlock, Event, EventType, Session, SessionContext, Stats,
23};
24
25#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
29#[serde(rename_all = "snake_case")]
30#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
31#[cfg_attr(feature = "ts", ts(export))]
32pub enum SortOrder {
33 #[default]
34 Recent,
35 Popular,
36 Longest,
37}
38
39impl SortOrder {
40 pub fn as_str(&self) -> &str {
41 match self {
42 Self::Recent => "recent",
43 Self::Popular => "popular",
44 Self::Longest => "longest",
45 }
46 }
47}
48
49impl std::fmt::Display for SortOrder {
50 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51 f.write_str(self.as_str())
52 }
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
57#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
58#[cfg_attr(feature = "ts", ts(export))]
59pub enum TimeRange {
60 #[serde(rename = "24h")]
61 Hours24,
62 #[serde(rename = "7d")]
63 Days7,
64 #[serde(rename = "30d")]
65 Days30,
66 #[default]
67 #[serde(rename = "all")]
68 All,
69}
70
71impl TimeRange {
72 pub fn as_str(&self) -> &str {
73 match self {
74 Self::Hours24 => "24h",
75 Self::Days7 => "7d",
76 Self::Days30 => "30d",
77 Self::All => "all",
78 }
79 }
80}
81
82impl std::fmt::Display for TimeRange {
83 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84 f.write_str(self.as_str())
85 }
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
90#[serde(rename_all = "snake_case")]
91#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
92#[cfg_attr(feature = "ts", ts(export))]
93pub enum LinkType {
94 Handoff,
95 Related,
96 Parent,
97 Child,
98}
99
100impl LinkType {
101 pub fn as_str(&self) -> &str {
102 match self {
103 Self::Handoff => "handoff",
104 Self::Related => "related",
105 Self::Parent => "parent",
106 Self::Child => "child",
107 }
108 }
109}
110
111impl std::fmt::Display for LinkType {
112 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113 f.write_str(self.as_str())
114 }
115}
116
117pub fn saturating_i64(v: u64) -> i64 {
121 i64::try_from(v).unwrap_or(i64::MAX)
122}
123
124#[derive(Debug, Serialize, Deserialize)]
128#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
129#[cfg_attr(feature = "ts", ts(export))]
130pub struct AuthRegisterRequest {
131 pub email: String,
132 pub password: String,
133 pub nickname: String,
134}
135
136#[derive(Debug, Serialize, Deserialize)]
138#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
139#[cfg_attr(feature = "ts", ts(export))]
140pub struct LoginRequest {
141 pub email: String,
142 pub password: String,
143}
144
145#[derive(Debug, Serialize, Deserialize)]
147#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
148#[cfg_attr(feature = "ts", ts(export))]
149pub struct AuthTokenResponse {
150 pub access_token: String,
151 pub refresh_token: String,
152 pub expires_in: u64,
153 pub user_id: String,
154 pub nickname: String,
155}
156
157#[derive(Debug, Serialize, Deserialize)]
159#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
160#[cfg_attr(feature = "ts", ts(export))]
161pub struct RefreshRequest {
162 pub refresh_token: String,
163}
164
165#[derive(Debug, Serialize, Deserialize)]
167#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
168#[cfg_attr(feature = "ts", ts(export))]
169pub struct LogoutRequest {
170 pub refresh_token: String,
171}
172
173#[derive(Debug, Serialize, Deserialize)]
175#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
176#[cfg_attr(feature = "ts", ts(export))]
177pub struct ChangePasswordRequest {
178 pub current_password: String,
179 pub new_password: String,
180}
181
182#[derive(Debug, Serialize, Deserialize)]
184#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
185#[cfg_attr(feature = "ts", ts(export))]
186pub struct VerifyResponse {
187 pub user_id: String,
188 pub nickname: String,
189}
190
191#[derive(Debug, Serialize, Deserialize)]
193#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
194#[cfg_attr(feature = "ts", ts(export))]
195pub struct UserSettingsResponse {
196 pub user_id: String,
197 pub nickname: String,
198 pub created_at: String,
199 pub email: Option<String>,
200 pub avatar_url: Option<String>,
201 #[serde(default)]
203 pub oauth_providers: Vec<oauth::LinkedProvider>,
204}
205
206#[derive(Debug, Serialize, Deserialize)]
208#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
209#[cfg_attr(feature = "ts", ts(export))]
210pub struct OkResponse {
211 pub ok: bool,
212}
213
214#[derive(Debug, Serialize, Deserialize)]
216#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
217#[cfg_attr(feature = "ts", ts(export))]
218pub struct IssueApiKeyResponse {
219 pub api_key: String,
220}
221
222#[derive(Debug, Serialize)]
224#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
225#[cfg_attr(feature = "ts", ts(export))]
226pub struct OAuthLinkResponse {
227 pub url: String,
228}
229
230#[derive(Debug, Serialize, Deserialize)]
234pub struct UploadRequest {
235 pub session: Session,
236 #[serde(default, skip_serializing_if = "Option::is_none")]
237 pub body_url: Option<String>,
238 #[serde(default, skip_serializing_if = "Option::is_none")]
239 pub linked_session_ids: Option<Vec<String>>,
240 #[serde(default, skip_serializing_if = "Option::is_none")]
241 pub git_remote: Option<String>,
242 #[serde(default, skip_serializing_if = "Option::is_none")]
243 pub git_branch: Option<String>,
244 #[serde(default, skip_serializing_if = "Option::is_none")]
245 pub git_commit: Option<String>,
246 #[serde(default, skip_serializing_if = "Option::is_none")]
247 pub git_repo_name: Option<String>,
248 #[serde(default, skip_serializing_if = "Option::is_none")]
249 pub pr_number: Option<i64>,
250 #[serde(default, skip_serializing_if = "Option::is_none")]
251 pub pr_url: Option<String>,
252 #[serde(default, skip_serializing_if = "Option::is_none")]
253 pub score_plugin: Option<String>,
254}
255
256#[derive(Debug, Serialize, Deserialize)]
258#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
259#[cfg_attr(feature = "ts", ts(export))]
260pub struct UploadResponse {
261 pub id: String,
262 pub url: String,
263 #[serde(default)]
264 pub session_score: i64,
265 #[serde(default = "default_score_plugin")]
266 pub score_plugin: String,
267}
268
269#[derive(Debug, Clone, Serialize, Deserialize)]
272#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
273#[cfg_attr(feature = "ts", ts(export))]
274pub struct SessionSummary {
275 pub id: String,
276 pub user_id: Option<String>,
277 pub nickname: Option<String>,
278 pub tool: String,
279 pub agent_provider: Option<String>,
280 pub agent_model: Option<String>,
281 pub title: Option<String>,
282 pub description: Option<String>,
283 pub tags: Option<String>,
285 pub created_at: String,
286 pub uploaded_at: String,
287 pub message_count: i64,
288 pub task_count: i64,
289 pub event_count: i64,
290 pub duration_seconds: i64,
291 pub total_input_tokens: i64,
292 pub total_output_tokens: i64,
293 #[serde(default, skip_serializing_if = "Option::is_none")]
294 pub git_remote: Option<String>,
295 #[serde(default, skip_serializing_if = "Option::is_none")]
296 pub git_branch: Option<String>,
297 #[serde(default, skip_serializing_if = "Option::is_none")]
298 pub git_commit: Option<String>,
299 #[serde(default, skip_serializing_if = "Option::is_none")]
300 pub git_repo_name: Option<String>,
301 #[serde(default, skip_serializing_if = "Option::is_none")]
302 pub pr_number: Option<i64>,
303 #[serde(default, skip_serializing_if = "Option::is_none")]
304 pub pr_url: Option<String>,
305 #[serde(default, skip_serializing_if = "Option::is_none")]
306 pub working_directory: Option<String>,
307 #[serde(default, skip_serializing_if = "Option::is_none")]
308 pub files_modified: Option<String>,
309 #[serde(default, skip_serializing_if = "Option::is_none")]
310 pub files_read: Option<String>,
311 #[serde(default)]
312 pub has_errors: bool,
313 #[serde(default = "default_max_active_agents")]
314 pub max_active_agents: i64,
315 #[serde(default)]
316 pub session_score: i64,
317 #[serde(default = "default_score_plugin")]
318 pub score_plugin: String,
319}
320
321#[derive(Debug, Serialize, Deserialize)]
323#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
324#[cfg_attr(feature = "ts", ts(export))]
325pub struct SessionListResponse {
326 pub sessions: Vec<SessionSummary>,
327 pub total: i64,
328 pub page: u32,
329 pub per_page: u32,
330}
331
332#[derive(Debug, Deserialize)]
334#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
335#[cfg_attr(feature = "ts", ts(export))]
336pub struct SessionListQuery {
337 #[serde(default = "default_page")]
338 pub page: u32,
339 #[serde(default = "default_per_page")]
340 pub per_page: u32,
341 pub search: Option<String>,
342 pub tool: Option<String>,
343 pub sort: Option<SortOrder>,
345 pub time_range: Option<TimeRange>,
347}
348
349impl SessionListQuery {
350 pub fn is_public_feed_cacheable(
352 &self,
353 has_auth_header: bool,
354 has_session_cookie: bool,
355 ) -> bool {
356 !has_auth_header
357 && !has_session_cookie
358 && self.search.as_deref().is_none_or(|s| s.trim().is_empty())
359 && self.page <= 10
360 && self.per_page <= 50
361 }
362}
363
364#[cfg(test)]
365mod session_list_query_tests {
366 use super::*;
367
368 fn base_query() -> SessionListQuery {
369 SessionListQuery {
370 page: 1,
371 per_page: 20,
372 search: None,
373 tool: None,
374 sort: None,
375 time_range: None,
376 }
377 }
378
379 #[test]
380 fn public_feed_cacheable_when_anonymous_default_feed() {
381 let q = base_query();
382 assert!(q.is_public_feed_cacheable(false, false));
383 }
384
385 #[test]
386 fn public_feed_not_cacheable_with_auth_or_cookie() {
387 let q = base_query();
388 assert!(!q.is_public_feed_cacheable(true, false));
389 assert!(!q.is_public_feed_cacheable(false, true));
390 }
391
392 #[test]
393 fn public_feed_not_cacheable_for_search_or_large_page() {
394 let mut q = base_query();
395 q.search = Some("hello".into());
396 assert!(!q.is_public_feed_cacheable(false, false));
397
398 let mut q = base_query();
399 q.page = 11;
400 assert!(!q.is_public_feed_cacheable(false, false));
401
402 let mut q = base_query();
403 q.per_page = 100;
404 assert!(!q.is_public_feed_cacheable(false, false));
405 }
406}
407
408fn default_page() -> u32 {
409 1
410}
411fn default_per_page() -> u32 {
412 20
413}
414fn default_max_active_agents() -> i64 {
415 1
416}
417
418fn default_score_plugin() -> String {
419 opensession_core::scoring::DEFAULT_SCORE_PLUGIN.to_string()
420}
421
422#[derive(Debug, Serialize, Deserialize)]
424#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
425#[cfg_attr(feature = "ts", ts(export))]
426pub struct SessionDetail {
427 #[serde(flatten)]
428 #[cfg_attr(feature = "ts", ts(flatten))]
429 pub summary: SessionSummary,
430 #[serde(default, skip_serializing_if = "Vec::is_empty")]
431 pub linked_sessions: Vec<SessionLink>,
432}
433
434#[derive(Debug, Clone, Serialize, Deserialize)]
436#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
437#[cfg_attr(feature = "ts", ts(export))]
438pub struct SessionLink {
439 pub session_id: String,
440 pub linked_session_id: String,
441 pub link_type: LinkType,
442 pub created_at: String,
443}
444
445#[derive(Debug, Clone, Serialize, Deserialize)]
447#[serde(tag = "kind", rename_all = "snake_case")]
448#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
449#[cfg_attr(feature = "ts", ts(export))]
450pub enum ParseSource {
451 Git {
453 remote: String,
454 r#ref: String,
455 path: String,
456 },
457 Github {
459 owner: String,
460 repo: String,
461 r#ref: String,
462 path: String,
463 },
464 Inline {
466 filename: String,
467 content_base64: String,
469 },
470}
471
472#[derive(Debug, Clone, Serialize, Deserialize)]
474#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
475#[cfg_attr(feature = "ts", ts(export))]
476pub struct ParseCandidate {
477 pub id: String,
478 pub confidence: u8,
479 pub reason: String,
480}
481
482#[derive(Debug, Clone, Serialize, Deserialize)]
484#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
485#[cfg_attr(feature = "ts", ts(export))]
486pub struct ParsePreviewRequest {
487 pub source: ParseSource,
488 #[serde(default, skip_serializing_if = "Option::is_none")]
489 pub parser_hint: Option<String>,
490}
491
492#[derive(Debug, Clone, Serialize, Deserialize)]
494#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
495#[cfg_attr(feature = "ts", ts(export))]
496pub struct ParsePreviewResponse {
497 pub parser_used: String,
498 #[serde(default)]
499 pub parser_candidates: Vec<ParseCandidate>,
500 #[cfg_attr(feature = "ts", ts(type = "any"))]
501 pub session: Session,
502 pub source: ParseSource,
503 #[serde(default)]
504 pub warnings: Vec<String>,
505 #[serde(default, skip_serializing_if = "Option::is_none")]
506 pub native_adapter: Option<String>,
507}
508
509#[derive(Debug, Clone, Serialize, Deserialize)]
511#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
512#[cfg_attr(feature = "ts", ts(export))]
513pub struct ParsePreviewErrorResponse {
514 pub code: String,
515 pub message: String,
516 #[serde(default, skip_serializing_if = "Vec::is_empty")]
517 pub parser_candidates: Vec<ParseCandidate>,
518}
519
520#[derive(Debug, Clone, Serialize, Deserialize)]
522#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
523#[cfg_attr(feature = "ts", ts(export))]
524pub struct LocalReviewBundle {
525 pub review_id: String,
526 pub generated_at: String,
527 pub pr: LocalReviewPrMeta,
528 #[serde(default)]
529 pub commits: Vec<LocalReviewCommit>,
530 #[serde(default)]
531 pub sessions: Vec<LocalReviewSession>,
532}
533
534#[derive(Debug, Clone, Serialize, Deserialize)]
536#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
537#[cfg_attr(feature = "ts", ts(export))]
538pub struct LocalReviewPrMeta {
539 pub url: String,
540 pub owner: String,
541 pub repo: String,
542 pub number: u64,
543 pub remote: String,
544 pub base_sha: String,
545 pub head_sha: String,
546}
547
548#[derive(Debug, Clone, Serialize, Deserialize)]
550#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
551#[cfg_attr(feature = "ts", ts(export))]
552pub struct LocalReviewCommit {
553 pub sha: String,
554 pub title: String,
555 pub author_name: String,
556 pub author_email: String,
557 pub authored_at: String,
558 #[serde(default)]
559 pub session_ids: Vec<String>,
560}
561
562#[derive(Debug, Clone, Serialize, Deserialize)]
564#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
565#[cfg_attr(feature = "ts", ts(export))]
566pub struct LocalReviewSession {
567 pub session_id: String,
568 pub ledger_ref: String,
569 pub hail_path: String,
570 #[serde(default)]
571 pub commit_shas: Vec<String>,
572 #[cfg_attr(feature = "ts", ts(type = "any"))]
573 pub session: Session,
574}
575
576#[derive(Debug, Deserialize)]
580#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
581#[cfg_attr(feature = "ts", ts(export))]
582pub struct StreamEventsRequest {
583 #[cfg_attr(feature = "ts", ts(type = "any"))]
584 pub agent: Option<Agent>,
585 #[cfg_attr(feature = "ts", ts(type = "any"))]
586 pub context: Option<SessionContext>,
587 #[cfg_attr(feature = "ts", ts(type = "any[]"))]
588 pub events: Vec<Event>,
589}
590
591#[derive(Debug, Serialize, Deserialize)]
593#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
594#[cfg_attr(feature = "ts", ts(export))]
595pub struct StreamEventsResponse {
596 pub accepted: usize,
597}
598
599#[derive(Debug, Serialize, Deserialize)]
603#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
604#[cfg_attr(feature = "ts", ts(export))]
605pub struct HealthResponse {
606 pub status: String,
607 pub version: String,
608}
609
610#[derive(Debug, Serialize, Deserialize)]
612#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
613#[cfg_attr(feature = "ts", ts(export))]
614pub struct CapabilitiesResponse {
615 pub auth_enabled: bool,
616 pub parse_preview_enabled: bool,
617 pub register_targets: Vec<String>,
618 pub share_modes: Vec<String>,
619}
620
621pub const DEFAULT_REGISTER_TARGETS: &[&str] = &["local", "git"];
622pub const DEFAULT_SHARE_MODES: &[&str] = &["web", "git", "json"];
623
624impl CapabilitiesResponse {
625 pub fn for_runtime(auth_enabled: bool, parse_preview_enabled: bool) -> Self {
627 Self {
628 auth_enabled,
629 parse_preview_enabled,
630 register_targets: DEFAULT_REGISTER_TARGETS
631 .iter()
632 .map(|target| (*target).to_string())
633 .collect(),
634 share_modes: DEFAULT_SHARE_MODES
635 .iter()
636 .map(|mode| (*mode).to_string())
637 .collect(),
638 }
639 }
640}
641
642#[derive(Debug, Clone)]
649#[non_exhaustive]
650pub enum ServiceError {
651 BadRequest(String),
652 Unauthorized(String),
653 Forbidden(String),
654 NotFound(String),
655 Conflict(String),
656 Internal(String),
657}
658
659impl ServiceError {
660 pub fn status_code(&self) -> u16 {
662 match self {
663 Self::BadRequest(_) => 400,
664 Self::Unauthorized(_) => 401,
665 Self::Forbidden(_) => 403,
666 Self::NotFound(_) => 404,
667 Self::Conflict(_) => 409,
668 Self::Internal(_) => 500,
669 }
670 }
671
672 pub fn code(&self) -> &'static str {
674 match self {
675 Self::BadRequest(_) => "bad_request",
676 Self::Unauthorized(_) => "unauthorized",
677 Self::Forbidden(_) => "forbidden",
678 Self::NotFound(_) => "not_found",
679 Self::Conflict(_) => "conflict",
680 Self::Internal(_) => "internal",
681 }
682 }
683
684 pub fn message(&self) -> &str {
686 match self {
687 Self::BadRequest(m)
688 | Self::Unauthorized(m)
689 | Self::Forbidden(m)
690 | Self::NotFound(m)
691 | Self::Conflict(m)
692 | Self::Internal(m) => m,
693 }
694 }
695
696 pub fn from_db<E: std::fmt::Display>(context: &str) -> impl FnOnce(E) -> Self + '_ {
698 move |e| Self::Internal(format!("{context}: {e}"))
699 }
700}
701
702impl std::fmt::Display for ServiceError {
703 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
704 write!(f, "{}", self.message())
705 }
706}
707
708impl std::error::Error for ServiceError {}
709
710#[derive(Debug, Serialize)]
714#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
715#[cfg_attr(feature = "ts", ts(export))]
716pub struct ApiError {
717 pub code: String,
718 pub message: String,
719}
720
721impl From<&ServiceError> for ApiError {
722 fn from(e: &ServiceError) -> Self {
723 Self {
724 code: e.code().to_string(),
725 message: e.message().to_string(),
726 }
727 }
728}
729
730#[cfg(test)]
733mod schema_tests {
734 use super::*;
735
736 #[test]
737 fn parse_preview_request_round_trip_git() {
738 let req = ParsePreviewRequest {
739 source: ParseSource::Git {
740 remote: "https://github.com/hwisu/opensession".to_string(),
741 r#ref: "main".to_string(),
742 path: "sessions/demo.hail.jsonl".to_string(),
743 },
744 parser_hint: Some("hail".to_string()),
745 };
746
747 let json = serde_json::to_string(&req).expect("request should serialize");
748 let decoded: ParsePreviewRequest =
749 serde_json::from_str(&json).expect("request should deserialize");
750
751 match decoded.source {
752 ParseSource::Git {
753 remote,
754 r#ref,
755 path,
756 } => {
757 assert_eq!(remote, "https://github.com/hwisu/opensession");
758 assert_eq!(r#ref, "main");
759 assert_eq!(path, "sessions/demo.hail.jsonl");
760 }
761 _ => panic!("expected git parse source"),
762 }
763 assert_eq!(decoded.parser_hint.as_deref(), Some("hail"));
764 }
765
766 #[test]
767 fn parse_preview_request_round_trip_github_compat() {
768 let req = ParsePreviewRequest {
769 source: ParseSource::Github {
770 owner: "hwisu".to_string(),
771 repo: "opensession".to_string(),
772 r#ref: "main".to_string(),
773 path: "sessions/demo.hail.jsonl".to_string(),
774 },
775 parser_hint: Some("hail".to_string()),
776 };
777
778 let json = serde_json::to_string(&req).expect("request should serialize");
779 let decoded: ParsePreviewRequest =
780 serde_json::from_str(&json).expect("request should deserialize");
781
782 match decoded.source {
783 ParseSource::Github {
784 owner,
785 repo,
786 r#ref,
787 path,
788 } => {
789 assert_eq!(owner, "hwisu");
790 assert_eq!(repo, "opensession");
791 assert_eq!(r#ref, "main");
792 assert_eq!(path, "sessions/demo.hail.jsonl");
793 }
794 _ => panic!("expected github parse source"),
795 }
796 assert_eq!(decoded.parser_hint.as_deref(), Some("hail"));
797 }
798
799 #[test]
800 fn parse_preview_error_response_round_trip_with_candidates() {
801 let payload = ParsePreviewErrorResponse {
802 code: "parser_selection_required".to_string(),
803 message: "choose parser".to_string(),
804 parser_candidates: vec![ParseCandidate {
805 id: "codex".to_string(),
806 confidence: 89,
807 reason: "event markers".to_string(),
808 }],
809 };
810
811 let json = serde_json::to_string(&payload).expect("error payload should serialize");
812 let decoded: ParsePreviewErrorResponse =
813 serde_json::from_str(&json).expect("error payload should deserialize");
814
815 assert_eq!(decoded.code, "parser_selection_required");
816 assert_eq!(decoded.parser_candidates.len(), 1);
817 assert_eq!(decoded.parser_candidates[0].id, "codex");
818 }
819
820 #[test]
821 fn local_review_bundle_round_trip() {
822 let mut sample_session = Session::new(
823 "s-review-1".to_string(),
824 Agent {
825 provider: "openai".to_string(),
826 model: "gpt-5".to_string(),
827 tool: "codex".to_string(),
828 tool_version: None,
829 },
830 );
831 sample_session.recompute_stats();
832
833 let payload = LocalReviewBundle {
834 review_id: "gh-org-repo-pr1-abc1234".to_string(),
835 generated_at: "2026-02-24T00:00:00Z".to_string(),
836 pr: LocalReviewPrMeta {
837 url: "https://github.com/org/repo/pull/1".to_string(),
838 owner: "org".to_string(),
839 repo: "repo".to_string(),
840 number: 1,
841 remote: "origin".to_string(),
842 base_sha: "a".repeat(40),
843 head_sha: "b".repeat(40),
844 },
845 commits: vec![LocalReviewCommit {
846 sha: "c".repeat(40),
847 title: "feat: add review flow".to_string(),
848 author_name: "Alice".to_string(),
849 author_email: "alice@example.com".to_string(),
850 authored_at: "2026-02-24T00:00:00Z".to_string(),
851 session_ids: vec!["s-review-1".to_string()],
852 }],
853 sessions: vec![LocalReviewSession {
854 session_id: "s-review-1".to_string(),
855 ledger_ref: "refs/remotes/origin/opensession/branches/bWFpbg".to_string(),
856 hail_path: "v1/se/s-review-1.hail.jsonl".to_string(),
857 commit_shas: vec!["c".repeat(40)],
858 session: sample_session,
859 }],
860 };
861
862 let json = serde_json::to_string(&payload).expect("review bundle should serialize");
863 let decoded: LocalReviewBundle =
864 serde_json::from_str(&json).expect("review bundle should deserialize");
865
866 assert_eq!(decoded.review_id, "gh-org-repo-pr1-abc1234");
867 assert_eq!(decoded.pr.number, 1);
868 assert_eq!(decoded.commits.len(), 1);
869 assert_eq!(decoded.sessions.len(), 1);
870 assert_eq!(decoded.sessions[0].session_id, "s-review-1");
871 }
872
873 #[test]
874 fn capabilities_response_round_trip_includes_new_fields() {
875 let caps = CapabilitiesResponse::for_runtime(true, true);
876
877 let json = serde_json::to_string(&caps).expect("capabilities should serialize");
878 let decoded: CapabilitiesResponse =
879 serde_json::from_str(&json).expect("capabilities should deserialize");
880
881 assert!(decoded.auth_enabled);
882 assert!(decoded.parse_preview_enabled);
883 assert_eq!(decoded.register_targets, vec!["local", "git"]);
884 assert_eq!(decoded.share_modes, vec!["web", "git", "json"]);
885 }
886
887 #[test]
888 fn capabilities_defaults_are_stable() {
889 assert_eq!(DEFAULT_REGISTER_TARGETS, &["local", "git"]);
890 assert_eq!(DEFAULT_SHARE_MODES, &["web", "git", "json"]);
891 }
892}
893
894#[cfg(all(test, feature = "ts"))]
895mod tests {
896 use super::*;
897 use std::io::Write;
898 use std::path::PathBuf;
899 use ts_rs::TS;
900
901 #[test]
903 fn export_typescript() {
904 let out_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
905 .join("../../packages/ui/src/api-types.generated.ts");
906
907 let cfg = ts_rs::Config::new().with_large_int("number");
908 let mut parts: Vec<String> = Vec::new();
909 parts.push("// AUTO-GENERATED by opensession-api — DO NOT EDIT".to_string());
910 parts.push(
911 "// Regenerate with: cargo test -p opensession-api -- export_typescript".to_string(),
912 );
913 parts.push(String::new());
914
915 macro_rules! collect_ts {
919 ($($t:ty),+ $(,)?) => {
920 $(
921 let decl = <$t>::decl(&cfg);
922 let is_struct_decl = decl.contains(" = {") && !decl.contains("} |");
923 let decl = if is_struct_decl {
924 decl
926 .replacen("type ", "export interface ", 1)
927 .replace(" = {", " {")
928 .trim_end_matches(';')
929 .to_string()
930 } else {
931 decl
933 .replacen("type ", "export type ", 1)
934 .trim_end_matches(';')
935 .to_string()
936 };
937 parts.push(decl);
938 parts.push(String::new());
939 )+
940 };
941 }
942
943 collect_ts!(
944 SortOrder,
946 TimeRange,
947 LinkType,
948 AuthRegisterRequest,
950 LoginRequest,
951 AuthTokenResponse,
952 RefreshRequest,
953 LogoutRequest,
954 ChangePasswordRequest,
955 VerifyResponse,
956 UserSettingsResponse,
957 OkResponse,
958 IssueApiKeyResponse,
959 OAuthLinkResponse,
960 UploadResponse,
962 SessionSummary,
963 SessionListResponse,
964 SessionListQuery,
965 SessionDetail,
966 SessionLink,
967 ParseSource,
968 ParseCandidate,
969 ParsePreviewRequest,
970 ParsePreviewResponse,
971 ParsePreviewErrorResponse,
972 LocalReviewBundle,
973 LocalReviewPrMeta,
974 LocalReviewCommit,
975 LocalReviewSession,
976 oauth::AuthProvidersResponse,
978 oauth::OAuthProviderInfo,
979 oauth::LinkedProvider,
980 HealthResponse,
982 CapabilitiesResponse,
983 ApiError,
984 );
985
986 let content = parts.join("\n");
987
988 if let Some(parent) = out_dir.parent() {
990 std::fs::create_dir_all(parent).ok();
991 }
992 let mut file = std::fs::File::create(&out_dir)
993 .unwrap_or_else(|e| panic!("Failed to create {}: {}", out_dir.display(), e));
994 file.write_all(content.as_bytes())
995 .unwrap_or_else(|e| panic!("Failed to write {}: {}", out_dir.display(), e));
996
997 println!("Generated TypeScript types at: {}", out_dir.display());
998 }
999}