Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

580 linesCodeBlameRaw
1//! A workspace's AI: the AI providers it keeps keys for, and what Ask g1t
2//! answers with. Kept by the integrations service (`src/ai.rs`), but not an
3//! integration: integrations connect outside products, from a directory
4//! others will publish to; a workspace's AI is one of its own settings, at
5//! `/:workspace/-/ai`.
6//!
7//! - **AI providers.** A frontier lab or a model platform at a known
8//! address, or a model the workspace hosts itself behind an Anthropic- or
9//! OpenAI-compatible endpoint. Owners add, test, change and remove them;
10//! keys are sealed and never returned.
11//! - **Ask g1t.** A conversation with g1t from the top bar and with @g1t in
12//! Chat, answered by the provider and model an owner chose, on the
13//! workspace's own key. g1t hosts no model for it and bills nothing for
14//! it; the key never leaves the service.
15//!
16//! Mirrors `packages/contracts/src/ai.ts`.
17
18use serde::{Deserialize, Serialize};
19
20use crate::repos::RepoPath;
21use crate::{User, Viewer};
22
23/// Which AI provider a workspace's key is for.
24#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum AiProvider {
27 // Frontier labs.
28 Anthropic,
29 Openai,
30 /// Through Gemini's OpenAI-compatible endpoint.
31 Gemini,
32 Xai,
33 Mistral,
34 Deepseek,
35 // Model platforms: many labs' models, or a deployment of your own.
36 /// A deployment on the workspace's own Azure OpenAI resource.
37 AzureOpenai,
38 Openrouter,
39 Groq,
40 Together,
41 Fireworks,
42 Cerebras,
43 // Self-hosted, or the workspace's own gateway.
44 /// Any endpoint that speaks Anthropic's Messages API: the workspace's
45 /// own gateway, LiteLLM, a proxy in front of Bedrock or Vertex, or a
46 /// self-hosted model.
47 AnthropicEndpoint,
48 /// Any endpoint that speaks OpenAI's Chat Completions API: vLLM,
49 /// Ollama behind a tunnel, LiteLLM, a gateway.
50 OpenaiEndpoint,
51}
52
53/// Where an AI provider's models come from, as people choose them.
54#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "snake_case")]
56pub enum AiGroup {
57 /// A lab's own API.
58 Labs,
59 /// A platform serving many labs' models.
60 Platforms,
61 /// A model the workspace hosts, or its own gateway.
62 SelfHosted,
63}
64
65/// What g1t knows about an AI provider.
66pub struct AiSpec {
67 pub provider: AiProvider,
68 pub name: &'static str,
69 pub label: &'static str,
70 pub group: AiGroup,
71 /// The API it speaks: `anthropic` or `openai`.
72 pub api: &'static str,
73 /// Where its API is, with the version for OpenAI's API and without it
74 /// for Anthropic's. Empty when the workspace gives its own.
75 pub base_url: &'static str,
76 /// The header the key goes in; `authorization` means `Bearer <key>`.
77 pub auth_header: &'static str,
78}
79
80const fn spec(
81 provider: AiProvider,
82 name: &'static str,
83 label: &'static str,
84 group: AiGroup,
85 api: &'static str,
86 base_url: &'static str,
87 auth_header: &'static str,
88) -> AiSpec {
89 AiSpec { provider, name, label, group, api, base_url, auth_header }
90}
91
92use AiGroup::{Labs, Platforms, SelfHosted};
93
94/// Every AI provider, in the order people are shown them.
95pub const AI_PROVIDERS: [AiSpec; 14] = [
96 spec(AiProvider::Anthropic, "anthropic", "Anthropic", Labs, "anthropic", "https://api.anthropic.com", "x-api-key"),
97 spec(AiProvider::Openai, "openai", "OpenAI", Labs, "openai", "https://api.openai.com/v1", "authorization"),
98 spec(AiProvider::Gemini, "gemini", "Google Gemini", Labs, "openai", "https://generativelanguage.googleapis.com/v1beta/openai", "authorization"),
99 spec(AiProvider::Xai, "xai", "xAI", Labs, "openai", "https://api.x.ai/v1", "authorization"),
100 spec(AiProvider::Mistral, "mistral", "Mistral", Labs, "openai", "https://api.mistral.ai/v1", "authorization"),
101 spec(AiProvider::Deepseek, "deepseek", "DeepSeek", Labs, "openai", "https://api.deepseek.com/v1", "authorization"),
102 spec(AiProvider::AzureOpenai, "azure_openai", "Azure OpenAI", Platforms, "openai", "", "api-key"),
103 spec(AiProvider::Openrouter, "openrouter", "OpenRouter", Platforms, "openai", "https://openrouter.ai/api/v1", "authorization"),
104 spec(AiProvider::Groq, "groq", "Groq", Platforms, "openai", "https://api.groq.com/openai/v1", "authorization"),
105 spec(AiProvider::Together, "together", "Together AI", Platforms, "openai", "https://api.together.xyz/v1", "authorization"),
106 spec(AiProvider::Fireworks, "fireworks", "Fireworks AI", Platforms, "openai", "https://api.fireworks.ai/inference/v1", "authorization"),
107 spec(AiProvider::Cerebras, "cerebras", "Cerebras", Platforms, "openai", "https://api.cerebras.ai/v1", "authorization"),
108 spec(AiProvider::AnthropicEndpoint, "anthropic_endpoint", "Anthropic-compatible endpoint", SelfHosted, "anthropic", "", "x-api-key"),
109 spec(AiProvider::OpenaiEndpoint, "openai_endpoint", "OpenAI-compatible endpoint", SelfHosted, "openai", "", "authorization"),
110];
111
112impl AiProvider {
113 pub fn spec(self) -> &'static AiSpec {
114 AI_PROVIDERS
115 .iter()
116 .find(|spec| spec.provider == self)
117 .expect("every AI provider is in the catalogue")
118 }
119
120 pub fn all() -> impl Iterator<Item = AiProvider> {
121 AI_PROVIDERS.iter().map(|spec| spec.provider)
122 }
123
124 pub fn name(self) -> &'static str {
125 self.spec().name
126 }
127
128 pub fn parse(name: &str) -> Option<AiProvider> {
129 AI_PROVIDERS.iter().find(|spec| spec.name == name).map(|spec| spec.provider)
130 }
131
132 /// What people call it.
133 pub fn label(self) -> &'static str {
134 self.spec().label
135 }
136
137 pub fn group(self) -> AiGroup {
138 self.spec().group
139 }
140
141 /// The API it speaks: `anthropic` or `openai`.
142 pub fn api(self) -> &'static str {
143 self.spec().api
144 }
145
146 /// Whether the workspace gives the address, rather than g1t knowing it.
147 pub fn own_address(self) -> bool {
148 self.spec().base_url.is_empty()
149 }
150}
151
152/// An AI provider's settings: everything but its key.
153#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
154#[serde(rename_all = "camelCase")]
155pub struct AiConfig {
156 /// For an endpoint or an Azure OpenAI resource: its address over
157 /// `https://`. An Anthropic-compatible one without `/v1`, an
158 /// OpenAI-compatible one up to and including the version.
159 #[serde(default, skip_serializing_if = "Option::is_none")]
160 pub base_url: Option<String>,
161 /// For an endpoint: send the key as `x-api-key` or as
162 /// `authorization: Bearer`.
163 #[serde(default, skip_serializing_if = "Option::is_none")]
164 pub auth_header: Option<String>,
165 /// The model the team uses on it; for Azure OpenAI, the deployment.
166 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub model: Option<String>,
168 /// For Anthropic: the Console workspace the key belongs to, as its
169 /// Console address names it (`wrkspc_…`), so links to the sessions it
170 /// starts open there. Without it, the account's Default workspace.
171 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub console_workspace: Option<String>,
173}
174
175/// One of a workspace's AI providers, as any member sees it. Its key is
176/// never shown after it is saved; `secretHint` tells keys apart.
177#[derive(Clone, Debug, Serialize, Deserialize)]
178#[serde(rename_all = "camelCase")]
179pub struct AiConnection {
180 pub id: String,
181 pub workspace: String,
182 pub provider: AiProvider,
183 pub group: AiGroup,
184 pub name: String,
185 pub config: AiConfig,
186 /// The last four characters of the saved key, such as `…3f9a`.
187 pub secret_hint: Option<String>,
188 pub created_by: String,
189 /// RFC 3339.
190 pub created_at: String,
191 pub last_used_at: Option<String>,
192 /// The last thing that went wrong talking to it, until it next works.
193 pub last_error: Option<String>,
194 /// The models it offered when last checked.
195 #[serde(default)]
196 pub models: Vec<String>,
197}
198
199// --- Methods -----------------------------------------------------------------
200
201/// `ai_list`. Returns `Outcome<Vec<AiConnection>>`. Members only.
202#[derive(Debug, Serialize, Deserialize)]
203pub struct ListAiArgs {
204 pub workspace: String,
205 pub viewer: Viewer,
206}
207
208/// `ai_add`. Returns `Outcome<AiConnection>`, after checking the key.
209/// Owners only.
210#[derive(Debug, Serialize, Deserialize)]
211pub struct AddAiArgs {
212 pub actor: User,
213 pub workspace: String,
214 pub provider: AiProvider,
215 #[serde(default)]
216 pub name: Option<String>,
217 #[serde(default)]
218 pub config: AiConfig,
219 /// Its API key. Optional for an endpoint that needs none.
220 #[serde(default)]
221 pub secret: Option<String>,
222}
223
224/// `ai_update`: only the fields given change. Returns
225/// `Outcome<AiConnection>`. Owners only.
226#[derive(Debug, Serialize, Deserialize)]
227pub struct UpdateAiArgs {
228 pub actor: User,
229 pub workspace: String,
230 pub id: String,
231 #[serde(default)]
232 pub name: Option<String>,
233 #[serde(default)]
234 pub config: Option<AiConfig>,
235 /// A new key, replacing the old one.
236 #[serde(default)]
237 pub secret: Option<String>,
238}
239
240/// `ai_remove` and `ai_test`. `ai_remove` returns `Outcome<bool>`;
241/// `ai_test` returns `Outcome<integrations::Tested>`. Owners only.
242#[derive(Debug, Serialize, Deserialize)]
243pub struct AiConnectionArgs {
244 pub actor: User,
245 pub workspace: String,
246 pub id: String,
247}
248
249// --- Ask g1t -----------------------------------------------------------------
250
251/// The most messages one ask sends: the conversation's latest turns.
252pub const ASK_MAX_MESSAGES: usize = 40;
253/// The longest one message may be, in characters.
254pub const ASK_MAX_MESSAGE_CHARS: usize = 16_000;
255/// The longest a whole conversation may be, in characters.
256pub const ASK_MAX_TOTAL_CHARS: usize = 64_000;
257/// The most tokens one answer may run to.
258pub const ASK_MAX_OUTPUT_TOKENS: u32 = 4_096;
259
260/// The model name that means Auto in [`SetAskSettingsArgs`]: Ask picks one
261/// of the provider's models for each question.
262pub const ASK_AUTO_MODEL: &str = "auto";
263
264/// What Ask g1t answers with in a workspace: the AI provider and model an
265/// owner chose, or why it cannot answer yet.
266#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
267pub struct AskSetup {
268 /// The workspace's AI provider Ask answers with: an [`AiConnection`]'s id.
269 pub provider_id: Option<String>,
270 /// Its name, as its owner named it.
271 pub provider_name: Option<String>,
272 pub provider: Option<AiProvider>,
273 /// The model chosen. With `auto`, none, or the provider's own model
274 /// when it lists none (an endpoint's, an Azure deployment).
275 pub model: Option<String>,
276 /// Auto: each question goes to the provider's model that suits it (a
277 /// fast one for a lookup, a stronger one for a question that needs
278 /// several reads or reasoning). The default.
279 #[serde(default)]
280 pub auto: bool,
281 /// Why Ask cannot answer, in words for the person asking. `None` when
282 /// it can.
283 pub problem: Option<String>,
284}
285
286/// `ai_brief`: a short answer from the AI provider a workspace chose for
287/// Ask, on its fast model, for g1t's own use (a session's title). Only
288/// services ask it, over their bindings. Returns `Outcome<Option<String>>`:
289/// none when the workspace has no provider chosen, a failure when the
290/// provider was asked and could not answer.
291#[derive(Debug, Serialize, Deserialize)]
292pub struct AiBriefArgs {
293 pub workspace: String,
294 pub system: String,
295 /// What to answer about, already bounded by the caller (at most
296 /// [`MAX_BRIEF_INPUT_CHARS`] are sent).
297 pub input: String,
298 /// At most [`MAX_BRIEF_TOKENS`].
299 pub max_tokens: u32,
300}
301
302/// The most of an [`AiBriefArgs`] input sent to the provider.
303pub const MAX_BRIEF_INPUT_CHARS: usize = 6_000;
304/// The longest answer an [`AiBriefArgs`] may ask for. The provider is
305/// given room beyond it to think first; the caller keeps only a short
306/// answer.
307pub const MAX_BRIEF_TOKENS: u32 = 200;
308
309/// `ask_settings`: what Ask answers with. Returns `Outcome<AskSetup>`.
310/// Members only.
311#[derive(Debug, Serialize, Deserialize)]
312pub struct AskSettingsArgs {
313 pub workspace: String,
314 pub viewer: Viewer,
315}
316
317/// `set_ask_settings`: chooses the AI provider and model Ask answers with;
318/// no `provider_id` turns Ask off. Returns `Outcome<AskSetup>`. Owners
319/// only.
320#[derive(Debug, Serialize, Deserialize)]
321pub struct SetAskSettingsArgs {
322 pub actor: User,
323 pub workspace: String,
324 #[serde(default)]
325 pub provider_id: Option<String>,
326 /// One of the models the provider offers, or [`ASK_AUTO_MODEL`] for
327 /// Auto; its own model (an Azure deployment) when not given.
328 #[serde(default)]
329 pub model: Option<String>,
330}
331
332#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
333#[serde(rename_all = "snake_case")]
334pub enum AskRole {
335 User,
336 Assistant,
337}
338
339/// One turn of an Ask conversation.
340#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
341pub struct AskMessage {
342 pub role: AskRole,
343 pub content: String,
344}
345
346/// Where the person asking is in g1t, so the answer is about their work.
347#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
348pub struct AskPage {
349 /// The page's path, such as `/acme/-/work/12`.
350 #[serde(default)]
351 pub path: Option<String>,
352 #[serde(default)]
353 pub repo: Option<RepoPath>,
354 /// The workspace of a page that is not a repository's, such as a work
355 /// item's. With `repo`, the repository's.
356 #[serde(default)]
357 pub workspace: Option<String>,
358 /// A work item, by number in the workspace.
359 #[serde(default)]
360 pub item: Option<u32>,
361 #[serde(default)]
362 pub pull: Option<u32>,
363 /// The browser's IANA time zone, such as `America/Denver`: what
364 /// "today" means when their profile names none.
365 #[serde(default)]
366 pub time_zone: Option<String>,
367}
368
369/// `ask`: one answer, streamed as newline-delimited JSON [`AskEvent`]s:
370/// `meta` first, then `text` as it comes and a `tool` line as each step
371/// starts and ends, then `sources` and `done`, or `error` at any point.
372/// Members only. The conversation alternates, starting and ending with the
373/// person; its turns are text only, since the steps of an answer live in
374/// that answer alone.
375///
376/// The provider reads g1t with the MCP server's own tools, as the person
377/// asking, on a read-only credential identity makes for the answer
378/// (`create_ask_token`), so it sees what they may see and changes nothing.
379#[derive(Debug, Serialize, Deserialize)]
380pub struct AskArgs {
381 pub actor: User,
382 pub workspace: String,
383 pub messages: Vec<AskMessage>,
384 #[serde(default)]
385 pub page: AskPage,
386}
387
388/// The most rounds of tool calls one answer takes before it must answer
389/// with what it has.
390pub const ASK_MAX_TOOL_ROUNDS: usize = 10;
391/// The most tool calls one answer makes, across its rounds.
392pub const ASK_MAX_TOOL_CALLS: usize = 30;
393/// The most characters of one tool's result the model is given; more is
394/// trimmed, long texts and lists first, with a note saying so.
395pub const ASK_MAX_TOOL_RESULT_CHARS: usize = 16_000;
396/// The most characters of tool results one answer gives the model in all.
397pub const ASK_MAX_TOOL_TOTAL_CHARS: usize = 120_000;
398/// How long one answer may spend calling tools, in seconds. After it, the
399/// model answers with what it has.
400pub const ASK_MAX_TOOL_SECONDS: u64 = 120;
401/// The most sources one answer lists.
402pub const ASK_MAX_SOURCES: usize = 12;
403
404/// Where a step of an answer is.
405#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
406#[serde(rename_all = "snake_case")]
407pub enum AskToolStatus {
408 /// Started; its result has not come.
409 Running,
410 Done,
411 /// The tool answered with a refusal or an error: `summary` says what.
412 Failed,
413 /// Ask did not run it: it is not something Ask does (it only reads, and
414 /// hands work off), or the answer had used its tool calls.
415 Refused,
416}
417
418/// A page on g1t an answer drew on.
419#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
420pub struct AskSource {
421 pub title: String,
422 /// An absolute address on the site, such as
423 /// `https://g1t.sh/acme/web/pull/7`.
424 pub url: String,
425 /// Whether the answer links to it, rather than only having read it.
426 pub cited: bool,
427}
428
429/// One line of an `ask` answer.
430#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
431#[serde(tag = "type", rename_all = "snake_case")]
432pub enum AskEvent {
433 /// Who is answering.
434 /// `model` is the one answering; `auto` when Auto chose it.
435 Meta {
436 provider: AiProvider,
437 provider_label: String,
438 model: String,
439 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
440 auto: bool,
441 },
442 /// The next piece of the answer.
443 Text { text: String },
444 /// A step taken to answer: one of g1t's tools, run as the person
445 /// asking. Sent when it starts, `running`, and again with the same `id`
446 /// when it ends, with what it found in `summary` and the page it was
447 /// about in `link`.
448 Tool {
449 id: String,
450 /// The tool, such as `pull_request`, and its `action`, such as `list`.
451 name: String,
452 action: Option<String>,
453 /// What it does, for a person: "Read pull requests in acme/web".
454 label: String,
455 status: AskToolStatus,
456 summary: Option<String>,
457 link: Option<String>,
458 },
459 /// What the answer drew on, cited first. Sent once, before `done`.
460 Sources { sources: Vec<AskSource> },
461 /// Work Ask proposes to hand to a session in the person's own harness:
462 /// on a work item (by number in `repo`'s workspace), or on a new one
463 /// with `title` and `body`. Nothing has started; the person starts it,
464 /// or copies the command, from the card the answer shows. `mode` is
465 /// `plan`, `implement` or `review`.
466 ///
467 /// With `new_repository`, `repo` does not exist yet: the card creates
468 /// it (`private` or public), turns on its deployments when `deploy`,
469 /// then opens the work item and starts the session, which pushes the
470 /// first version to the default branch, and so deploys it.
471 Proposal {
472 id: String,
473 repo: String,
474 item: Option<u64>,
475 title: Option<String>,
476 body: Option<String>,
477 mode: String,
478 instruction: Option<String>,
479 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
480 new_repository: bool,
481 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
482 private: bool,
483 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
484 deploy: bool,
485 },
486 /// The answer is complete. `stop_reason` as the provider said it, such
487 /// as `end_turn`, `stop` or `max_tokens`.
488 Done { stop_reason: Option<String> },
489 /// The answer stopped: what went wrong, for the person asking.
490 Error { message: String },
491}
492
493#[cfg(test)]
494mod tests {
495 use super::*;
496
497 #[test]
498 fn every_ai_provider_is_named_once_and_found_again() {
499 let mut names: Vec<&str> = AI_PROVIDERS.iter().map(|spec| spec.name).collect();
500 for provider in AiProvider::all() {
501 assert_eq!(AiProvider::parse(provider.name()), Some(provider));
502 }
503 names.sort();
504 names.dedup();
505 assert_eq!(names.len(), AI_PROVIDERS.len());
506 // Integrations are not AI providers.
507 assert_eq!(AiProvider::parse("sentry"), None);
508 }
509
510 #[test]
511 fn ai_providers_say_how_to_reach_them() {
512 for spec in &AI_PROVIDERS {
513 assert!(spec.api == "anthropic" || spec.api == "openai", "{}", spec.name);
514 assert!(!spec.auth_header.is_empty(), "{}", spec.name);
515 assert!(spec.base_url.is_empty() || spec.base_url.starts_with("https://"), "{}", spec.name);
516 }
517 let stored: AiProvider = serde_json::from_str("\"openai_endpoint\"").unwrap();
518 assert!(stored.own_address());
519 assert_eq!(stored.group(), AiGroup::SelfHosted);
520 assert!(!AiProvider::Anthropic.own_address());
521 assert_eq!(serde_json::to_string(&AiGroup::SelfHosted).unwrap(), r#""self_hosted""#);
522 }
523
524 #[test]
525 fn settings_kept_from_when_they_were_integrations_still_parse() {
526 let config: AiConfig =
527 serde_json::from_str(r#"{"writeBack":true,"keys":[],"baseUrl":"https://llm.acme.dev","model":"qwen3"}"#).unwrap();
528 assert_eq!(config.base_url.as_deref(), Some("https://llm.acme.dev"));
529 assert_eq!(config.model.as_deref(), Some("qwen3"));
530 }
531
532 #[test]
533 fn ask_events_are_tagged_lines() {
534 let line = serde_json::to_string(&AskEvent::Text { text: "Hi".into() }).unwrap();
535 assert_eq!(line, r#"{"type":"text","text":"Hi"}"#);
536 let meta: AskEvent =
537 serde_json::from_str(r#"{"type":"meta","provider":"anthropic","provider_label":"Anthropic","model":"claude"}"#).unwrap();
538 assert_eq!(
539 meta,
540 AskEvent::Meta { provider: AiProvider::Anthropic, provider_label: "Anthropic".into(), model: "claude".into(), auto: false }
541 );
542 // Auto says so; a chosen model's line is as it was.
543 assert!(!serde_json::to_string(&meta).unwrap().contains("auto"));
544 let auto = AskEvent::Meta { provider: AiProvider::Anthropic, provider_label: "Anthropic".into(), model: "claude".into(), auto: true };
545 assert_eq!(
546 serde_json::to_string(&auto).unwrap(),
547 r#"{"type":"meta","provider":"anthropic","provider_label":"Anthropic","model":"claude","auto":true}"#
548 );
549 let page: AskPage = serde_json::from_str("{}").unwrap();
550 assert_eq!(page, AskPage::default());
551 }
552
553 #[test]
554 fn ask_steps_and_sources_are_snake_case_lines() {
555 let step = AskEvent::Tool {
556 id: "t1".into(),
557 name: "pull_request".into(),
558 action: Some("list".into()),
559 label: "Read pull requests in acme/web".into(),
560 status: AskToolStatus::Done,
561 summary: Some("12 results".into()),
562 link: Some("https://g1t.sh/acme/web/pulls".into()),
563 };
564 assert_eq!(
565 serde_json::to_value(&step).unwrap(),
566 serde_json::json!({
567 "type": "tool", "id": "t1", "name": "pull_request", "action": "list",
568 "label": "Read pull requests in acme/web", "status": "done",
569 "summary": "12 results", "link": "https://g1t.sh/acme/web/pulls",
570 })
571 );
572 let sources = AskEvent::Sources {
573 sources: vec![AskSource { title: "Fix login".into(), url: "https://g1t.sh/acme/web/pull/7".into(), cited: true }],
574 };
575 let line = serde_json::to_string(&sources).unwrap();
576 assert_eq!(line, r#"{"type":"sources","sources":[{"title":"Fix login","url":"https://g1t.sh/acme/web/pull/7","cited":true}]}"#);
577 assert_eq!(serde_json::from_str::<AskEvent>(&line).unwrap(), sources);
578 assert_eq!(serde_json::to_string(&AskToolStatus::Refused).unwrap(), r#""refused""#);
579 }
580}