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.

578 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.
305pub const MAX_BRIEF_TOKENS: u32 = 200;
306
307/// `ask_settings`: what Ask answers with. Returns `Outcome<AskSetup>`.
308/// Members only.
309#[derive(Debug, Serialize, Deserialize)]
310pub struct AskSettingsArgs {
311 pub workspace: String,
312 pub viewer: Viewer,
313}
314
315/// `set_ask_settings`: chooses the AI provider and model Ask answers with;
316/// no `provider_id` turns Ask off. Returns `Outcome<AskSetup>`. Owners
317/// only.
318#[derive(Debug, Serialize, Deserialize)]
319pub struct SetAskSettingsArgs {
320 pub actor: User,
321 pub workspace: String,
322 #[serde(default)]
323 pub provider_id: Option<String>,
324 /// One of the models the provider offers, or [`ASK_AUTO_MODEL`] for
325 /// Auto; its own model (an Azure deployment) when not given.
326 #[serde(default)]
327 pub model: Option<String>,
328}
329
330#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
331#[serde(rename_all = "snake_case")]
332pub enum AskRole {
333 User,
334 Assistant,
335}
336
337/// One turn of an Ask conversation.
338#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
339pub struct AskMessage {
340 pub role: AskRole,
341 pub content: String,
342}
343
344/// Where the person asking is in g1t, so the answer is about their work.
345#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
346pub struct AskPage {
347 /// The page's path, such as `/acme/-/work/12`.
348 #[serde(default)]
349 pub path: Option<String>,
350 #[serde(default)]
351 pub repo: Option<RepoPath>,
352 /// The workspace of a page that is not a repository's, such as a work
353 /// item's. With `repo`, the repository's.
354 #[serde(default)]
355 pub workspace: Option<String>,
356 /// A work item, by number in the workspace.
357 #[serde(default)]
358 pub item: Option<u32>,
359 #[serde(default)]
360 pub pull: Option<u32>,
361 /// The browser's IANA time zone, such as `America/Denver`: what
362 /// "today" means when their profile names none.
363 #[serde(default)]
364 pub time_zone: Option<String>,
365}
366
367/// `ask`: one answer, streamed as newline-delimited JSON [`AskEvent`]s:
368/// `meta` first, then `text` as it comes and a `tool` line as each step
369/// starts and ends, then `sources` and `done`, or `error` at any point.
370/// Members only. The conversation alternates, starting and ending with the
371/// person; its turns are text only, since the steps of an answer live in
372/// that answer alone.
373///
374/// The provider reads g1t with the MCP server's own tools, as the person
375/// asking, on a read-only credential identity makes for the answer
376/// (`create_ask_token`), so it sees what they may see and changes nothing.
377#[derive(Debug, Serialize, Deserialize)]
378pub struct AskArgs {
379 pub actor: User,
380 pub workspace: String,
381 pub messages: Vec<AskMessage>,
382 #[serde(default)]
383 pub page: AskPage,
384}
385
386/// The most rounds of tool calls one answer takes before it must answer
387/// with what it has.
388pub const ASK_MAX_TOOL_ROUNDS: usize = 10;
389/// The most tool calls one answer makes, across its rounds.
390pub const ASK_MAX_TOOL_CALLS: usize = 30;
391/// The most characters of one tool's result the model is given; more is
392/// trimmed, long texts and lists first, with a note saying so.
393pub const ASK_MAX_TOOL_RESULT_CHARS: usize = 16_000;
394/// The most characters of tool results one answer gives the model in all.
395pub const ASK_MAX_TOOL_TOTAL_CHARS: usize = 120_000;
396/// How long one answer may spend calling tools, in seconds. After it, the
397/// model answers with what it has.
398pub const ASK_MAX_TOOL_SECONDS: u64 = 120;
399/// The most sources one answer lists.
400pub const ASK_MAX_SOURCES: usize = 12;
401
402/// Where a step of an answer is.
403#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
404#[serde(rename_all = "snake_case")]
405pub enum AskToolStatus {
406 /// Started; its result has not come.
407 Running,
408 Done,
409 /// The tool answered with a refusal or an error: `summary` says what.
410 Failed,
411 /// Ask did not run it: it is not something Ask does (it only reads, and
412 /// hands work off), or the answer had used its tool calls.
413 Refused,
414}
415
416/// A page on g1t an answer drew on.
417#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
418pub struct AskSource {
419 pub title: String,
420 /// An absolute address on the site, such as
421 /// `https://g1t.sh/acme/web/pull/7`.
422 pub url: String,
423 /// Whether the answer links to it, rather than only having read it.
424 pub cited: bool,
425}
426
427/// One line of an `ask` answer.
428#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
429#[serde(tag = "type", rename_all = "snake_case")]
430pub enum AskEvent {
431 /// Who is answering.
432 /// `model` is the one answering; `auto` when Auto chose it.
433 Meta {
434 provider: AiProvider,
435 provider_label: String,
436 model: String,
437 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
438 auto: bool,
439 },
440 /// The next piece of the answer.
441 Text { text: String },
442 /// A step taken to answer: one of g1t's tools, run as the person
443 /// asking. Sent when it starts, `running`, and again with the same `id`
444 /// when it ends, with what it found in `summary` and the page it was
445 /// about in `link`.
446 Tool {
447 id: String,
448 /// The tool, such as `pull_request`, and its `action`, such as `list`.
449 name: String,
450 action: Option<String>,
451 /// What it does, for a person: "Read pull requests in acme/web".
452 label: String,
453 status: AskToolStatus,
454 summary: Option<String>,
455 link: Option<String>,
456 },
457 /// What the answer drew on, cited first. Sent once, before `done`.
458 Sources { sources: Vec<AskSource> },
459 /// Work Ask proposes to hand to a session in the person's own harness:
460 /// on a work item (by number in `repo`'s workspace), or on a new one
461 /// with `title` and `body`. Nothing has started; the person starts it,
462 /// or copies the command, from the card the answer shows. `mode` is
463 /// `plan`, `implement` or `review`.
464 ///
465 /// With `new_repository`, `repo` does not exist yet: the card creates
466 /// it (`private` or public), turns on its deployments when `deploy`,
467 /// then opens the work item and starts the session, which pushes the
468 /// first version to the default branch, and so deploys it.
469 Proposal {
470 id: String,
471 repo: String,
472 item: Option<u64>,
473 title: Option<String>,
474 body: Option<String>,
475 mode: String,
476 instruction: Option<String>,
477 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
478 new_repository: bool,
479 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
480 private: bool,
481 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
482 deploy: bool,
483 },
484 /// The answer is complete. `stop_reason` as the provider said it, such
485 /// as `end_turn`, `stop` or `max_tokens`.
486 Done { stop_reason: Option<String> },
487 /// The answer stopped: what went wrong, for the person asking.
488 Error { message: String },
489}
490
491#[cfg(test)]
492mod tests {
493 use super::*;
494
495 #[test]
496 fn every_ai_provider_is_named_once_and_found_again() {
497 let mut names: Vec<&str> = AI_PROVIDERS.iter().map(|spec| spec.name).collect();
498 for provider in AiProvider::all() {
499 assert_eq!(AiProvider::parse(provider.name()), Some(provider));
500 }
501 names.sort();
502 names.dedup();
503 assert_eq!(names.len(), AI_PROVIDERS.len());
504 // Integrations are not AI providers.
505 assert_eq!(AiProvider::parse("sentry"), None);
506 }
507
508 #[test]
509 fn ai_providers_say_how_to_reach_them() {
510 for spec in &AI_PROVIDERS {
511 assert!(spec.api == "anthropic" || spec.api == "openai", "{}", spec.name);
512 assert!(!spec.auth_header.is_empty(), "{}", spec.name);
513 assert!(spec.base_url.is_empty() || spec.base_url.starts_with("https://"), "{}", spec.name);
514 }
515 let stored: AiProvider = serde_json::from_str("\"openai_endpoint\"").unwrap();
516 assert!(stored.own_address());
517 assert_eq!(stored.group(), AiGroup::SelfHosted);
518 assert!(!AiProvider::Anthropic.own_address());
519 assert_eq!(serde_json::to_string(&AiGroup::SelfHosted).unwrap(), r#""self_hosted""#);
520 }
521
522 #[test]
523 fn settings_kept_from_when_they_were_integrations_still_parse() {
524 let config: AiConfig =
525 serde_json::from_str(r#"{"writeBack":true,"keys":[],"baseUrl":"https://llm.acme.dev","model":"qwen3"}"#).unwrap();
526 assert_eq!(config.base_url.as_deref(), Some("https://llm.acme.dev"));
527 assert_eq!(config.model.as_deref(), Some("qwen3"));
528 }
529
530 #[test]
531 fn ask_events_are_tagged_lines() {
532 let line = serde_json::to_string(&AskEvent::Text { text: "Hi".into() }).unwrap();
533 assert_eq!(line, r#"{"type":"text","text":"Hi"}"#);
534 let meta: AskEvent =
535 serde_json::from_str(r#"{"type":"meta","provider":"anthropic","provider_label":"Anthropic","model":"claude"}"#).unwrap();
536 assert_eq!(
537 meta,
538 AskEvent::Meta { provider: AiProvider::Anthropic, provider_label: "Anthropic".into(), model: "claude".into(), auto: false }
539 );
540 // Auto says so; a chosen model's line is as it was.
541 assert!(!serde_json::to_string(&meta).unwrap().contains("auto"));
542 let auto = AskEvent::Meta { provider: AiProvider::Anthropic, provider_label: "Anthropic".into(), model: "claude".into(), auto: true };
543 assert_eq!(
544 serde_json::to_string(&auto).unwrap(),
545 r#"{"type":"meta","provider":"anthropic","provider_label":"Anthropic","model":"claude","auto":true}"#
546 );
547 let page: AskPage = serde_json::from_str("{}").unwrap();
548 assert_eq!(page, AskPage::default());
549 }
550
551 #[test]
552 fn ask_steps_and_sources_are_snake_case_lines() {
553 let step = AskEvent::Tool {
554 id: "t1".into(),
555 name: "pull_request".into(),
556 action: Some("list".into()),
557 label: "Read pull requests in acme/web".into(),
558 status: AskToolStatus::Done,
559 summary: Some("12 results".into()),
560 link: Some("https://g1t.sh/acme/web/pulls".into()),
561 };
562 assert_eq!(
563 serde_json::to_value(&step).unwrap(),
564 serde_json::json!({
565 "type": "tool", "id": "t1", "name": "pull_request", "action": "list",
566 "label": "Read pull requests in acme/web", "status": "done",
567 "summary": "12 results", "link": "https://g1t.sh/acme/web/pulls",
568 })
569 );
570 let sources = AskEvent::Sources {
571 sources: vec![AskSource { title: "Fix login".into(), url: "https://g1t.sh/acme/web/pull/7".into(), cited: true }],
572 };
573 let line = serde_json::to_string(&sources).unwrap();
574 assert_eq!(line, r#"{"type":"sources","sources":[{"title":"Fix login","url":"https://g1t.sh/acme/web/pull/7","cited":true}]}"#);
575 assert_eq!(serde_json::from_str::<AskEvent>(&line).unwrap(), sources);
576 assert_eq!(serde_json::to_string(&AskToolStatus::Refused).unwrap(), r#""refused""#);
577 }
578}