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.

1,308 linesCodeBlameRaw
1//! Session titles that settle: named from the whole of a session's record
2//! by g1t, not chased turn by turn by its agent.
3//!
4//! A title says where it came from (`TitleSource`):
5//!
6//! - **prompt**: provisional. The first prompt with an intent names a
7//! session as it records it ([`prompted_sql`]), until g1t settles one.
8//! - **agent**: its harness or agent named it (`g1t session attach
9//! --title`). Kept, unless the session went on to other work items or
10//! pull requests after it was named, when it is settled again as it
11//! ends. Its agent naming it again what g1t already calls it changes
12//! nothing.
13//! - **person**: named from the site: pinned, never replaced by g1t or
14//! the agent.
15//! - **item**, **pull**, **summary**: settled by g1t.
16//!
17//! g1t settles a title when one is due (`title_due_at`), in the cron
18//! ([`Work::settle_titles`]):
19//!
20//! 1. once there is enough to go on: the session was tied to a work item
21//! or pull request in a way that means it works on it (attached,
22//! claimed, opened, pushed, a commit or trailer; not a `#N` mention),
23//! it recorded [`PROMPTS_TO_SETTLE`] prompts, or
24//! [`SETTLE_AFTER_MINUTES`] passed since its title was provisional;
25//! 2. again only when what it works on changed (another work item or pull
26//! request, or its prompts grew past [`more_prompts`]), at most every
27//! [`RESETTLE_MINUTES`], and only when the new title differs from the
28//! one it has in more than wording ([`similar`]);
29//! 3. a last time as it ends, from everything it did (`title_final`).
30//!
31//! The title is the workspace's AI provider's (the one Ask answers with,
32//! asked through integrations' `ai_brief` on its fast model) when the
33//! record is more than one work item or pull request, with a bounded
34//! digest of the record ([`digest`]): prompts as recorded (credentials
35//! already out), the work items and pull requests, subagents' tasks,
36//! where it changed files, its commits and what it reported. At most
37//! [`MAX_AI_CALLS`] per session. Without a provider, or when it fails,
38//! [`fallback`]: the work item's title, else the pull request's, else
39//! what the most telling prompt asks for, without its preamble.
40//!
41//! A subagent keeps its task as what it is called and is never titled.
42
43use std::collections::{HashMap, HashSet};
44
45use g1t_contracts::ai::{AiBriefArgs, MAX_BRIEF_INPUT_CHARS};
46use g1t_contracts::repos::{PathByIdArgs, RepoPath};
47use g1t_contracts::time::rfc3339;
48use g1t_contracts::work::*;
49use g1t_contracts::Outcome;
50use g1t_kit::now_ms;
51use serde::Deserialize;
52use worker::Result;
53
54use super::{SessionRow, shown};
55use crate::Work;
56
57/// Prompts after which a provisional title is settled.
58pub(crate) const PROMPTS_TO_SETTLE: u32 = 3;
59/// Minutes after which a provisional title is settled, prompts or not.
60pub(crate) const SETTLE_AFTER_MINUTES: u32 = 30;
61/// The least time between two settles of one session's title.
62pub(crate) const RESETTLE_MINUTES: u32 = 20;
63/// The most times one session's title is asked of the AI provider.
64pub(crate) const MAX_AI_CALLS: u32 = 5;
65/// The most AI calls one cron run makes.
66const RUN_AI_CALLS: u32 = 8;
67/// The most sessions one cron run settles that became due lately, and
68/// that were due long ago (the backfill).
69const RUN_LIVE: u32 = 15;
70const RUN_BACKFILL: u32 = 40;
71/// How long a due title counts as live, not backfill.
72const LIVE_MS: u64 = 24 * 60 * 60 * 1000;
73/// The longest title g1t gives.
74pub(crate) const TITLE_CHARS: usize = 80;
75/// The most prompts, and characters of each, a digest holds.
76const DIGEST_PROMPTS: usize = 6;
77const DIGEST_PROMPT_CHARS: usize = 400;
78
79/// What the AI provider is told.
80pub(crate) const SYSTEM: &str = "You name a coding session for its team's list of work. Reply with only a title of 3-8 words naming the whole of the work the session did, in the team's terms (use a work item's wording when it fits), no trailing period, no quotes, no prefix.";
81
82/// The time `minutes` after `column`, as stored, or `now` (`?N`) when later.
83fn not_before(now: &str, column: &str, minutes: u32) -> String {
84 format!("MAX({now}, COALESCE(strftime('%Y-%m-%dT%H:%M:%fZ', {column}, '+{minutes} minutes'), {now}))")
85}
86
87/// The prompts after which a settled title is due again: twice as many as
88/// it was settled with, five more at least.
89pub(crate) fn more_prompts(settled_with: u32) -> u32 {
90 settled_with + settled_with.max(5)
91}
92
93/// SQL to set as a session ends at `at` (an expression): its title is due
94/// a last time, unless it is a subagent's or pinned.
95pub(crate) fn final_due(at: &str) -> String {
96 format!("title_due_at = CASE WHEN parent_id IS NULL AND COALESCE(title_source, '') <> 'person' THEN {at} ELSE title_due_at END")
97}
98
99/// Session `?1` recorded `?2` prompts, the first of them with intent `?3`
100/// (or none), at `?4`; `?5` is `?4` plus [`SETTLE_AFTER_MINUTES`]. An
101/// untitled session at the top of its tree takes the intent as its
102/// provisional title; a provisional one is due at `?5`, or now once it
103/// has [`PROMPTS_TO_SETTLE`] prompts; a settled one is due again once its
104/// prompts grew past [`more_prompts`], not before [`RESETTLE_MINUTES`].
105pub(crate) fn prompted_sql() -> String {
106 format!(
107 "UPDATE sessions SET prompts = prompts + ?2,
108 title = CASE WHEN title IS NULL AND parent_id IS NULL THEN ?3 ELSE title END,
109 title_source = CASE WHEN title IS NULL AND parent_id IS NULL AND ?3 IS NOT NULL THEN 'prompt' ELSE title_source END,
110 title_due_at = CASE
111 WHEN parent_id IS NOT NULL OR title_final = 1 OR title_source IN ('person', 'agent') THEN title_due_at
112 WHEN title IS NULL OR title_source IS NULL OR title_source = 'prompt' THEN
113 CASE WHEN prompts + ?2 >= {PROMPTS_TO_SETTLE} THEN ?4 ELSE COALESCE(title_due_at, ?5) END
114 WHEN title_due_at IS NULL AND prompts + ?2 >= title_prompts + MAX(title_prompts, 5) THEN {later}
115 ELSE title_due_at END
116 WHERE id = ?1",
117 later = not_before("?4", "title_settled_at", RESETTLE_MINUTES),
118 )
119}
120
121/// Session `?1` was tied, at `?2`, to a work item or pull request it works
122/// on: a provisional title is due now, a settled one again not before
123/// [`RESETTLE_MINUTES`] after it settled. Not a subagent's, a pinned one
124/// or its agent's (settled as it ends), nor one settled for good.
125pub(crate) fn linked_sql() -> String {
126 let due = format!(
127 "CASE WHEN title_source IN ('item', 'pull', 'summary') THEN {} ELSE ?2 END",
128 not_before("?2", "title_settled_at", RESETTLE_MINUTES)
129 );
130 format!(
131 "UPDATE sessions SET title_due_at = MIN(COALESCE(title_due_at, {due}), {due})
132 WHERE id = ?1 AND parent_id IS NULL AND title_final = 0
133 AND COALESCE(title_source, '') NOT IN ('person', 'agent')"
134 )
135}
136
137/// A title its agent gives (`?5` of the heartbeat): pinned ones keep
138/// theirs. `?13` is where it came from ([`given_source`]), `?11` now. An
139/// agent saying again the title it gave changes nothing: it settled when
140/// first given, and is as stale as it was.
141pub(crate) const SET_TITLE: &str = "title = CASE WHEN ?5 IS NULL OR title_source = 'person' THEN title ELSE ?5 END,
142 title_due_at = CASE WHEN ?5 IS NULL OR title_source = 'person' OR ?13 <> 'agent'
143 OR (title_source = 'agent' AND lower(title) = lower(?5)) THEN title_due_at ELSE NULL END,
144 title_settled_at = CASE WHEN ?5 IS NULL OR title_source = 'person' OR ?13 <> 'agent'
145 OR (title_source = 'agent' AND lower(title) = lower(?5)) THEN title_settled_at ELSE ?11 END,
146 title_source = CASE WHEN ?5 IS NULL OR title_source = 'person' THEN title_source ELSE ?13 END";
147
148/// A person names session `?3` `?1` at `?2`: pinned.
149pub(crate) const PIN_TITLE: &str = "UPDATE sessions SET title = ?1, title_source = 'person', title_settled_at = ?2, title_due_at = NULL
150 WHERE id = ?3";
151
152/// A person unpins session `?2`'s title at `?1`: g1t names it again.
153pub(crate) const UNPIN_TITLE: &str = "UPDATE sessions SET title_source = 'prompt', title_due_at = ?1, title_final = 0
154 WHERE id = ?2 AND title_source = 'person'";
155
156/// Where a title an agent gives came from: its agent's, unless it is what
157/// the session is provisionally called already (an older plugin echoing
158/// the first prompt's intent back), which stays provisional.
159pub(crate) fn given_source(given: &str, current: Option<&str>, source: Option<TitleSource>) -> TitleSource {
160 match (current, source) {
161 (Some(current), None | Some(TitleSource::Prompt)) if same_words(given, current) => TitleSource::Prompt,
162 (Some(current), Some(kept)) if kept.settled() && same_words(given, current) => kept,
163 _ => TitleSource::Agent,
164 }
165}
166
167// --- Reading prompts ---------------------------------------------------------
168
169/// Removes each `<open…>…</close>` block (to the end when it is not closed).
170fn drop_blocks(text: &str, open: &str, close: &str) -> String {
171 let mut out = String::with_capacity(text.len());
172 let lower = text.to_ascii_lowercase();
173 let mut at = 0;
174 while let Some(found) = lower[at..].find(open) {
175 let start = at + found;
176 // `<pasted_content` must be the tag itself, not a longer name.
177 let next = lower[start + open.len()..].chars().next();
178 if next.is_some_and(|c| c.is_ascii_alphanumeric() || c == '_') {
179 out.push_str(&text[at..start + open.len()]);
180 at = start + open.len();
181 continue;
182 }
183 out.push_str(&text[at..start]);
184 out.push('\n');
185 match lower[start..].find(close) {
186 Some(end) => {
187 let after = start + end + close.len();
188 // The rest of the closing tag.
189 at = lower[after..].find('>').map_or(after, |gt| after + gt + 1);
190 }
191 None => {
192 at = text.len();
193 }
194 }
195 }
196 out.push_str(&text[at..]);
197 out
198}
199
200/// Removes `[Image #1]` and `[Image: source: …]` placeholders.
201fn drop_images(text: &str) -> String {
202 let mut out = String::with_capacity(text.len());
203 let mut rest = text;
204 while let Some(start) = rest.find("[Image") {
205 out.push_str(&rest[..start]);
206 let tail = &rest[start + "[Image".len()..];
207 let ok = tail.starts_with(']') || tail.starts_with(" #") || tail.starts_with(':');
208 match (ok, tail.find(']')) {
209 (true, Some(end)) if !tail[..end].contains('\n') => {
210 out.push(' ');
211 rest = &tail[end + 1..];
212 }
213 _ => {
214 out.push_str("[Image");
215 rest = tail;
216 }
217 }
218 }
219 out.push_str(rest);
220 out
221}
222
223/// Whether `text` starts with a tag like `<command-name>` or `<task-notification id="…">`.
224fn starts_with_tag(text: &str) -> bool {
225 let text = text.trim_start();
226 let Some(rest) = text.strip_prefix('<') else { return false };
227 let mut chars = rest.chars();
228 if !chars.next().is_some_and(|c| c.is_ascii_alphabetic()) {
229 return false;
230 }
231 for c in chars {
232 if c == '>' {
233 return true;
234 }
235 if !(c.is_alphanumeric() || c == '-' || c == '_') {
236 return c.is_whitespace() && rest.contains('>');
237 }
238 }
239 false
240}
241
242/// Context an editor puts before what was typed, a whole block at a time.
243fn drop_leading_context(mut text: String) -> String {
244 loop {
245 let trimmed = text.trim_start();
246 let lower = trimmed.to_ascii_lowercase();
247 let name: String = if lower.starts_with("<ide_") || lower.starts_with("<system-reminder") {
248 lower[1..].chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-').collect()
249 } else {
250 return text;
251 };
252 let close = format!("</{name}>");
253 match lower.find(&close) {
254 Some(end) => text = trimmed[end + close.len()..].to_owned(),
255 None => return text,
256 }
257 }
258}
259
260/// Removes fenced code (``` and ~~~), to the end when a fence is not closed.
261fn drop_fences(text: &str, fence: &str) -> String {
262 let mut out = String::new();
263 let mut rest = text;
264 while let Some(start) = rest.find(fence) {
265 out.push_str(&rest[..start]);
266 out.push('\n');
267 let after = &rest[start + fence.len()..];
268 match after.find(fence) {
269 Some(end) => rest = &after[end + fence.len()..],
270 None => rest = "",
271 }
272 }
273 out.push_str(rest);
274 out
275}
276
277/// Markdown links and images as their text, inline code as itself.
278fn unmark(text: &str) -> String {
279 let mut out = String::with_capacity(text.len());
280 let chars: Vec<char> = text.chars().collect();
281 let mut i = 0;
282 while i < chars.len() {
283 let c = chars[i];
284 let image = c == '!' && chars.get(i + 1) == Some(&'[');
285 if c == '[' || image {
286 let open = if image { i + 1 } else { i };
287 if let Some(close) = chars[open + 1..].iter().position(|&c| c == ']').map(|p| open + 1 + p)
288 && chars.get(close + 1) == Some(&'(')
289 && let Some(end) = chars[close + 2..].iter().position(|&c| c == ')').map(|p| close + 2 + p)
290 {
291 out.extend(&chars[open + 1..close]);
292 i = end + 1;
293 continue;
294 }
295 }
296 if c != '`' {
297 out.push(c);
298 }
299 i += 1;
300 }
301 out
302}
303
304/// A line without its list, heading or quote markers and its emphasis.
305fn bare_line(line: &str) -> String {
306 let mut line = line.trim_start();
307 loop {
308 let hashes = line.chars().take_while(|&c| c == '#').count();
309 let digits = line.chars().take_while(char::is_ascii_digit).count();
310 let spaced = |at: usize| line[at..].starts_with(char::is_whitespace);
311 line = if (1..=6).contains(&hashes) && spaced(hashes) {
312 line[hashes..].trim_start()
313 } else if let Some(rest) = line.strip_prefix('>') {
314 rest.trim_start()
315 } else if line.starts_with(['-', '*', '+']) && spaced(1) {
316 line[1..].trim_start()
317 } else if digits > 0 && line[digits..].starts_with(['.', ')']) && spaced(digits + 1) {
318 line[digits + 1..].trim_start()
319 } else {
320 break;
321 };
322 }
323 line.replace("**", "").replace("__", "").replace("~~", "").trim().to_owned()
324}
325
326/// A prompt as words to read: what was pasted or attached, editor
327/// context, code and markdown taken out. None for a slash command or a
328/// turn the harness started itself.
329pub(crate) fn readable(prompt: &str) -> Option<String> {
330 let text = prompt.trim_start_matches('\u{feff}');
331 let text = drop_blocks(text, "<pasted_content", "</pasted_content");
332 let text = drop_images(&text);
333 let text = drop_leading_context(text);
334 if text.trim_start().starts_with('/') || starts_with_tag(&text) {
335 return None;
336 }
337 let text = drop_fences(&drop_fences(&text, "```"), "~~~");
338 Some(unmark(&text))
339}
340
341/// The lines of a readable prompt that say something.
342fn lines(text: &str) -> Vec<String> {
343 text.lines().map(bare_line).filter(|line| line.chars().any(char::is_alphanumeric)).collect()
344}
345
346/// Sentences: up to a stop followed by a space.
347fn sentences(text: &str) -> Vec<String> {
348 let text: String = text.split_whitespace().collect::<Vec<_>>().join(" ");
349 let mut out = Vec::new();
350 let mut current = String::new();
351 let chars: Vec<char> = text.chars().collect();
352 for (i, &c) in chars.iter().enumerate() {
353 current.push(c);
354 if matches!(c, '.' | '!' | '?') && chars.get(i + 1) == Some(&' ') && chars.get(i + 2).is_some() {
355 out.push(current.trim().to_owned());
356 current.clear();
357 }
358 }
359 if !current.trim().is_empty() {
360 out.push(current.trim().to_owned());
361 }
362 out
363}
364
365/// Cut to `limit` characters on a word, with "…"; trailing stops off.
366fn clip_title(text: &str, limit: usize) -> String {
367 let text = text.trim();
368 if text.chars().count() > limit {
369 let cut: String = text.chars().take(limit - 1).collect();
370 let at = cut.rfind(' ').filter(|&space| space > limit / 2).unwrap_or(cut.len());
371 format!("{}…", cut[..at].trim_end_matches([' ', ',', ';', ':', '.', '-', '–', '—']))
372 } else {
373 text.trim_end_matches([' ', ',', ';', ':', '.']).to_owned()
374 }
375}
376
377fn capitalized(text: &str) -> String {
378 let mut chars = text.chars();
379 match chars.next() {
380 Some(first) => first.to_uppercase().chain(chars).collect(),
381 None => String::new(),
382 }
383}
384
385/// What a prompt is for, as its session's provisional title: its first
386/// line's first sentence, clipped at [`TITLE_CHARS`] on a word. Reads a
387/// prompt as the Claude Code plugin's `intentTitle` did, and the site's
388/// does still (apps/web/app/lib/coordination.ts).
389pub(crate) fn intent_title(prompt: &str) -> Option<String> {
390 let text = readable(prompt)?;
391 let line = lines(&text).into_iter().next()?;
392 let first = sentences(&line).into_iter().next()?;
393 let intent = clip_title(&first, TITLE_CHARS);
394 (intent.chars().count() >= 2).then(|| capitalized(&intent))
395}
396
397/// Words a sentence starts with that say nothing of the work, taken off
398/// its front (longest first among those that share a start).
399const FILLERS: &[&str] = &[
400 "it would be great if you could", "it'd be great if you could", "i would like you to", "i'd like you to", "i want you to",
401 "i need you to", "would you please", "could you please", "can you please", "go ahead and", "i would like to", "i'd like to",
402 "i feel like", "i was thinking", "what i want is to", "i want to", "we need to", "we should", "you should", "i think", "i believe",
403 "i guess", "can you", "could you", "would you", "will you", "can we", "could we", "help me", "let's", "lets", "please", "so",
404 "ok", "okay", "alright", "all right", "well", "hey", "hi", "hello", "now", "basically", "actually", "also", "and", "but",
405 "then", "next", "right", "great", "thanks", "thank you", "cool", "nice", "perfect", "awesome", "yes", "yeah", "sure",
406];
407
408/// How a sentence starts when it sets the scene instead of asking.
409const PREAMBLES: &[&str] = &[
410 "this project", "this repo", "this repository", "this codebase", "this code", "this used to", "this was", "this is",
411 "it used to", "we used to", "i used to", "used to", "there is", "there are", "there was", "there were", "currently",
412 "right now", "at the moment", "as you know", "for context", "context", "background", "fyi", "i noticed", "i see", "i saw",
413 "i was", "i am", "i'm", "i have", "i've", "we have", "we've", "we were", "it looks like", "looks like", "it seems",
414 "it's", "it is", "the problem is", "the issue is", "here is", "here's", "you are", "you're", "my ", "our ",
415];
416
417/// Verbs a sentence that asks for work starts with.
418const ASKS: &[&str] = &[
419 "add", "build", "make", "implement", "move", "migrate", "update", "remove", "delete", "refactor", "rename", "write",
420 "create", "review", "investigate", "debug", "fix", "set", "port", "convert", "replace", "improve", "clean", "support",
421 "ship", "deploy", "release", "design", "document", "test", "rewrite", "change", "extract", "split", "merge", "upgrade",
422 "bump", "enable", "disable", "find", "figure", "wire", "hook", "handle", "allow", "prevent", "stop", "show", "hide",
423 "finish", "audit", "harden", "optimize", "speed", "reduce", "drop", "introduce", "integrate", "connect", "publish",
424 "configure", "install", "generate", "redesign", "restructure", "consolidate", "unify", "simplify", "polish", "tighten",
425 "rework", "backfill", "track", "record", "settle", "teach", "let", "get", "go", "look", "check", "explain", "plan", "draft",
426];
427
428/// Short answers that steer, not ask: never the most telling prompt.
429const STEERS: &[&str] = &[
430 "yes", "no", "ok", "okay", "continue", "go on", "go ahead", "do it", "lgtm", "commit", "push", "commit it", "push it",
431 "thanks", "thank you", "sure", "proceed", "keep going", "retry", "try again", "done", "great", "perfect", "nice", "y", "n",
432];
433
434fn lower_words(text: &str) -> String {
435 text.to_lowercase()
436}
437
438/// Whether `text` starts with `phrase` as whole words (a phrase ending in
439/// a space is a word that must have one after it).
440fn starts_with_words(text: &str, phrase: &str) -> bool {
441 if !text.starts_with(phrase) {
442 return false;
443 }
444 phrase.ends_with(' ') || text[phrase.len()..].chars().next().is_none_or(|c| !c.is_alphanumeric() && c != '\'')
445}
446
447/// A sentence without the words in front that say nothing of the work.
448fn without_fillers(sentence: &str) -> String {
449 let mut text = sentence.trim().to_owned();
450 loop {
451 let lower = lower_words(&text);
452 let Some(filler) = FILLERS.iter().find(|filler| starts_with_words(&lower, filler)) else { break };
453 if !text.is_char_boundary(filler.len()) {
454 break;
455 }
456 text = text[filler.len()..].trim_start_matches([' ', ',', ':', ';', '-', '—', '!', '.']).to_owned();
457 }
458 text
459}
460
461/// How well a sentence says what the work is.
462fn telling(sentence: &str) -> i32 {
463 let lower = lower_words(sentence);
464 let words = lower.split_whitespace().count();
465 if words == 0 {
466 return i32::MIN;
467 }
468 let mut score = 0;
469 if PREAMBLES.iter().any(|preamble| starts_with_words(&lower, preamble)) {
470 score -= 4;
471 }
472 let first = lower.split(|c: char| !c.is_alphanumeric()).next().unwrap_or_default();
473 if ASKS.contains(&first) {
474 score += 4;
475 }
476 if (3..=16).contains(&words) {
477 score += 1;
478 }
479 if words < 3 {
480 score -= 2;
481 }
482 if lower.ends_with('?') {
483 score -= 1;
484 }
485 score
486}
487
488/// What a prompt asks for, as a title: its most telling sentence among the
489/// first few, without greetings, preambles ("This project used to be …")
490/// and hedges ("I think", "So"). None when it says nothing.
491pub(crate) fn cleaned_intent(prompt: &str) -> Option<String> {
492 let (_, sentence) = best_sentence(prompt)?;
493 let title = clip_title(sentence.trim_end_matches(['?', '!']), TITLE_CHARS);
494 (title.chars().count() >= 2).then(|| capitalized(&title))
495}
496
497/// A prompt's most telling sentence, with how telling it is.
498fn best_sentence(prompt: &str) -> Option<(i32, String)> {
499 let text = readable(prompt)?;
500 let mut best: Option<(i32, String)> = None;
501 for sentence in lines(&text).iter().flat_map(|line| sentences(line)).take(8) {
502 let score = telling(&sentence);
503 let cleaned = without_fillers(&sentence);
504 if !cleaned.chars().any(char::is_alphanumeric) {
505 continue;
506 }
507 // The cleaned sentence may start with what it asks.
508 let score = score.max(telling(&cleaned));
509 if best.as_ref().is_none_or(|(kept, _)| score > *kept) {
510 best = Some((score, cleaned));
511 }
512 }
513 best
514}
515
516/// Whether a prompt only steers ("yes", "continue", "commit it").
517fn steers(prompt: &str) -> bool {
518 let text: String = prompt.trim().trim_end_matches(['.', '!', '?']).to_lowercase();
519 text.split_whitespace().count() <= 3 && STEERS.iter().any(|steer| text == *steer || text.starts_with(&format!("{steer} ")))
520 || text.split_whitespace().count() <= 1
521}
522
523/// The prompt that says best what the session was for, of `prompts`
524/// (oldest first): the first one wins ties, a steering one never does.
525pub(crate) fn telling_prompt(prompts: &[String]) -> Option<&str> {
526 let mut best: Option<(i32, &str)> = None;
527 for (index, prompt) in prompts.iter().enumerate() {
528 if steers(prompt) {
529 continue;
530 }
531 let Some((score, _)) = best_sentence(prompt) else { continue };
532 // What the session was started with counts for a little more.
533 let score = score + if index == 0 { 1 } else { 0 };
534 if best.is_none_or(|(kept, _)| score > kept) {
535 best = Some((score, prompt));
536 }
537 }
538 best.map(|(_, prompt)| prompt)
539}
540
541// --- Settling ----------------------------------------------------------------
542
543/// The words of a title that carry meaning, lowercased.
544fn meaning(title: &str) -> HashSet<String> {
545 const SMALL: &[&str] = &["the", "and", "for", "with", "from", "into", "onto", "that", "this", "its", "our", "your", "a", "an", "of", "to", "in", "on", "at", "by"];
546 title
547 .split(|c: char| !c.is_alphanumeric())
548 .map(str::to_lowercase)
549 .filter(|word| word.len() >= 2 && !SMALL.contains(&word.as_str()))
550 .map(|word| stem(&word))
551 .collect()
552}
553
554/// A word without the endings that make one word several: plural,
555/// -ing, -ed (`settles`, `settling`, `settled` are `settl`).
556fn stem(word: &str) -> String {
557 let mut word = word;
558 for ending in ["ing", "ed", "es", "s"] {
559 if word.len() > ending.len() + 2 && word.ends_with(ending) {
560 word = &word[..word.len() - ending.len()];
561 break;
562 }
563 }
564 if word.len() > 3 && word.ends_with('e') {
565 word = &word[..word.len() - 1];
566 }
567 word.to_owned()
568}
569
570/// Whether two titles have the same words, whatever their case and stops.
571pub(crate) fn same_words(a: &str, b: &str) -> bool {
572 let a: Vec<String> = a.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).map(str::to_lowercase).collect();
573 let b: Vec<String> = b.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).map(str::to_lowercase).collect();
574 !a.is_empty() && a == b
575}
576
577/// Whether two titles say much the same: a new title replaces a settled
578/// one only when they do not.
579pub(crate) fn similar(a: &str, b: &str) -> bool {
580 let (a, b) = (meaning(a), meaning(b));
581 if a.is_empty() || b.is_empty() {
582 return false;
583 }
584 let shared = a.intersection(&b).count() as f64;
585 let all = a.union(&b).count() as f64;
586 shared / all >= 0.6
587}
588
589/// The AI provider's answer as a title, if it is one: its first line,
590/// without quotes, a `Title:` prefix, markdown or a trailing stop; 2 to
591/// 12 words and at most [`TITLE_CHARS`].
592pub(crate) fn ai_title(answer: &str) -> Option<String> {
593 let line = answer.lines().map(str::trim).find(|line| !line.is_empty())?;
594 let mut line = line.trim_start_matches(['#', '*', '-', ' ']).trim();
595 for prefix in ["title:", "session title:", "session:"] {
596 if line.to_lowercase().starts_with(prefix) {
597 line = line[prefix.len()..].trim();
598 }
599 }
600 let line = line.replace("**", "").replace('`', "");
601 let line = line.trim().trim_matches(['"', '\'', '“', '”', '‘', '’', '*']).trim();
602 let line = line.trim_end_matches(['.', '!', ';', ':', ',']).trim();
603 let words = line.split_whitespace().count();
604 if !(2..=12).contains(&words) || line.chars().count() > TITLE_CHARS {
605 return None;
606 }
607 Some(capitalized(line))
608}
609
610/// A work item or pull request a session is tied to.
611#[derive(Clone, Debug, PartialEq)]
612pub(crate) struct Tied {
613 pub number: u32,
614 pub title: String,
615 /// It works on it (attached, claimed, opened, pushed), not only named it.
616 pub strong: bool,
617}
618
619/// What a session did, for naming it.
620#[derive(Clone, Debug, Default)]
621pub(crate) struct Record {
622 pub repo: String,
623 /// The work item it says it is on, and the pull request.
624 pub item: Option<u32>,
625 pub pull: Option<u32>,
626 pub items: Vec<Tied>,
627 pub pulls: Vec<Tied>,
628 /// Its prompts, oldest first (the first and last few).
629 pub prompts: Vec<String>,
630 pub tasks: Vec<String>,
631 /// Where it changed files, most first, with how many.
632 pub areas: Vec<(String, usize)>,
633 pub commits: usize,
634 pub result: Option<String>,
635 pub title: Option<String>,
636}
637
638impl Record {
639 /// The work items it works on, the one it says it is on first.
640 fn worked_items(&self) -> Vec<&Tied> {
641 let mut items: Vec<&Tied> = self.items.iter().filter(|item| item.strong || Some(item.number) == self.item).collect();
642 items.sort_by_key(|item| Some(item.number) != self.item);
643 items
644 }
645
646 fn worked_pulls(&self) -> Vec<&Tied> {
647 let mut pulls: Vec<&Tied> = self.pulls.iter().filter(|pull| pull.strong || Some(pull.number) == self.pull).collect();
648 pulls.sort_by_key(|pull| Some(pull.number) != self.pull);
649 pulls
650 }
651
652 /// What it was settled from: the work items and pull requests it works on.
653 pub(crate) fn basis(&self) -> String {
654 let mut parts: Vec<String> = self.worked_items().iter().map(|item| format!("i{}", item.number)).collect();
655 parts.extend(self.worked_pulls().iter().map(|pull| format!("p{}", pull.number)));
656 parts.sort();
657 parts.join(",")
658 }
659
660 /// Whether one work item, or one pull request, is the whole story:
661 /// its title is the session's, with no need to ask.
662 pub(crate) fn one_thing(&self) -> bool {
663 let items = self.worked_items().len();
664 let pulls = self.worked_pulls().len();
665 (items == 1 && pulls <= 1) || (items == 0 && pulls == 1)
666 }
667}
668
669/// The title g1t gives without an AI provider: the work item's title, else
670/// the pull request's, else what the most telling prompt asks for; and
671/// where it came from.
672pub(crate) fn fallback(record: &Record) -> Option<(String, TitleSource)> {
673 if let Some(item) = record.worked_items().first().filter(|item| !item.title.trim().is_empty()) {
674 return Some((clip_title(&item.title, TITLE_CHARS), TitleSource::Item));
675 }
676 if let Some(pull) = record.worked_pulls().first().filter(|pull| !pull.title.trim().is_empty()) {
677 return Some((clip_title(&pull.title, TITLE_CHARS), TitleSource::Pull));
678 }
679 if let Some(intent) = telling_prompt(&record.prompts).and_then(cleaned_intent) {
680 return Some((intent, TitleSource::Summary));
681 }
682 // With no prompts recorded: what its first subagent was given to do.
683 record.tasks.first().map(|task| (clip_title(&capitalized(task), TITLE_CHARS), TitleSource::Summary))
684}
685
686fn clipped(text: &str, limit: usize) -> String {
687 let text = text.split_whitespace().collect::<Vec<_>>().join(" ");
688 if text.chars().count() <= limit { text } else { format!("{}…", text.chars().take(limit - 1).collect::<String>()) }
689}
690
691/// What the AI provider is given: the record, bounded.
692pub(crate) fn digest(record: &Record) -> String {
693 let mut out = format!("Repository: {}\n", record.repo);
694 let named = |tied: &Tied| format!("- #{} {}\n", tied.number, clipped(&tied.title, 160));
695 if !record.items.is_empty() {
696 out.push_str("Work items it worked on:\n");
697 for item in record.worked_items().into_iter().take(6) {
698 out.push_str(&named(item));
699 }
700 let named_only: Vec<&Tied> = record.items.iter().filter(|item| !item.strong && Some(item.number) != record.item).take(4).collect();
701 if !named_only.is_empty() {
702 out.push_str("Work items it mentioned:\n");
703 for item in named_only {
704 out.push_str(&named(item));
705 }
706 }
707 }
708 if !record.worked_pulls().is_empty() {
709 out.push_str("Pull requests:\n");
710 for pull in record.worked_pulls().into_iter().take(6) {
711 out.push_str(&named(pull));
712 }
713 }
714 let prompts: Vec<String> = record.prompts.iter().filter_map(|prompt| readable(prompt)).map(|text| clipped(&text, DIGEST_PROMPT_CHARS)).filter(|text| !text.is_empty()).take(DIGEST_PROMPTS).collect();
715 if !prompts.is_empty() {
716 out.push_str("What the person asked, oldest first:\n");
717 for (index, prompt) in prompts.iter().enumerate() {
718 out.push_str(&format!("{}. {prompt}\n", index + 1));
719 }
720 }
721 if !record.tasks.is_empty() {
722 let tasks: Vec<String> = record.tasks.iter().take(10).map(|task| clipped(task, 100)).collect();
723 out.push_str(&format!("Subagents' tasks: {}\n", tasks.join("; ")));
724 }
725 if !record.areas.is_empty() {
726 let areas: Vec<String> = record.areas.iter().take(8).map(|(area, n)| format!("{area} ({n})")).collect();
727 out.push_str(&format!("Files changed in: {}\n", areas.join(", ")));
728 }
729 if record.commits > 0 {
730 out.push_str(&format!("Commits: {}\n", record.commits));
731 }
732 if let Some(result) = record.result.as_deref().filter(|result| !result.trim().is_empty()) {
733 out.push_str(&format!("What it reported at the end: {}\n", clipped(result, 500)));
734 }
735 if let Some(title) = &record.title {
736 out.push_str(&format!("Its title so far: {}\n", clipped(title, 120)));
737 }
738 clipped_lines(&out, MAX_BRIEF_INPUT_CHARS)
739}
740
741fn clipped_lines(text: &str, limit: usize) -> String {
742 if text.chars().count() <= limit {
743 return text.to_owned();
744 }
745 text.chars().take(limit).collect()
746}
747
748/// Where changed files are, as their first two directories, most first.
749pub(crate) fn areas(files: &[String]) -> Vec<(String, usize)> {
750 let mut counts: HashMap<String, usize> = HashMap::new();
751 for file in files {
752 let parts: Vec<&str> = file.trim_start_matches("./").split('/').collect();
753 let area = if parts.len() > 2 { parts[..2].join("/") } else if parts.len() == 2 { parts[0].to_owned() } else { ".".to_owned() };
754 *counts.entry(area).or_default() += 1;
755 }
756 let mut areas: Vec<(String, usize)> = counts.into_iter().collect();
757 areas.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
758 areas
759}
760
761/// What settling a title comes to.
762#[derive(Clone, Debug, PartialEq)]
763pub(crate) enum Settled {
764 /// Nothing changes, but it is no longer due (and final, when it ended).
765 Keep { source: Option<TitleSource> },
766 /// It is called this now.
767 Retitle { title: String, source: TitleSource },
768}
769
770/// What a due title's session is, for [`decide`].
771#[derive(Clone, Debug)]
772pub(crate) struct Due {
773 pub title: Option<String>,
774 pub source: Option<TitleSource>,
775 pub ended: bool,
776 /// For its agent's title: it went on to other work after it was named.
777 pub stale: bool,
778 /// What it was last settled from, and with how many prompts.
779 pub basis: Option<String>,
780 pub prompts: u32,
781 pub settled_prompts: u32,
782 pub settled_before: bool,
783}
784
785/// Whether a due title needs naming again at all (before any AI call).
786pub(crate) fn needs_naming(due: &Due, record: &Record) -> bool {
787 match due.source {
788 Some(TitleSource::Person) => false,
789 Some(TitleSource::Agent) => due.ended && due.stale,
790 Some(source) if source.settled() && due.settled_before => {
791 due.ended || due.basis.as_deref() != Some(record.basis().as_str()) || due.prompts >= more_prompts(due.settled_prompts)
792 }
793 _ => true,
794 }
795}
796
797/// Settles a due title with `candidate` (the AI provider's, else
798/// [`fallback`]'s): a pinned title stays, an agent's stays unless stale, a
799/// settled one changes only when the new one says something else.
800pub(crate) fn decide(due: &Due, record: &Record, candidate: Option<(String, TitleSource)>) -> Settled {
801 if !needs_naming(due, record) {
802 return Settled::Keep { source: due.source };
803 }
804 let Some((title, source)) = candidate else {
805 return Settled::Keep { source: due.source };
806 };
807 match (&due.title, due.source) {
808 (Some(current), Some(kept)) if kept != TitleSource::Prompt && similar(current, &title) => Settled::Keep { source: Some(kept) },
809 (Some(current), _) if same_words(current, &title) => Settled::Keep { source: Some(source) },
810 _ => Settled::Retitle { title, source },
811 }
812}
813
814// --- The cron ----------------------------------------------------------------
815
816#[derive(Deserialize)]
817struct DueRow {
818 id: String,
819 repo_id: String,
820 #[serde(default)]
821 title_basis: Option<String>,
822 #[serde(default)]
823 title_prompts: u32,
824 #[serde(default)]
825 title_calls: u32,
826 #[serde(default)]
827 prompts: u32,
828}
829
830const DUE_COLUMNS: &str = "id, repo_id, title_basis, title_prompts, title_calls, prompts";
831
832#[derive(Deserialize)]
833struct TextRow {
834 text: String,
835}
836
837#[derive(Deserialize)]
838struct CountRow {
839 n: u32,
840}
841
842/// Settles session `?1`'s title: `?2` title (or as it was), `?3` source,
843/// `?4` now, `?5` final, `?6` basis, `?7` prompts then, `?8` AI calls made.
844/// Not when a person pinned it meanwhile.
845const SETTLE: &str = "UPDATE sessions SET title = COALESCE(?2, title), title_source = ?3, title_settled_at = ?4,
846 title_due_at = NULL, title_final = ?5, title_basis = ?6, title_prompts = ?7, title_calls = title_calls + ?8
847 WHERE id = ?1 AND COALESCE(title_source, '') <> 'person'";
848
849/// What one cron run may still spend, and on whom.
850struct Budget {
851 calls: u32,
852 /// Workspaces whose provider answered nothing (none chosen) or failed.
853 without: HashSet<String>,
854 paths: HashMap<String, Option<RepoPath>>,
855}
856
857impl Work {
858 /// Settles the titles that are due: those that became due lately
859 /// first, then some of those due long ago (the backfill of sessions
860 /// from before titles settled), newest first. Bounded: see
861 /// [`RUN_LIVE`], [`RUN_BACKFILL`] and [`RUN_AI_CALLS`].
862 pub(crate) async fn settle_titles(&self) -> Result<u32> {
863 let now = now_ms();
864 let at = rfc3339(now);
865 let live_since = rfc3339(now.saturating_sub(LIVE_MS));
866 let live = self
867 .db
868 .prepare(format!(
869 "SELECT {DUE_COLUMNS} FROM sessions WHERE title_due_at IS NOT NULL AND title_due_at <= ?1 AND title_due_at > ?2
870 ORDER BY title_due_at LIMIT ?3"
871 ))
872 .bind(&[at.as_str().into(), live_since.as_str().into(), RUN_LIVE.into()])?
873 .all()
874 .await?
875 .results::<DueRow>()?;
876 let old = self
877 .db
878 .prepare(format!(
879 "SELECT {DUE_COLUMNS} FROM sessions WHERE title_due_at IS NOT NULL AND title_due_at <= ?1
880 ORDER BY title_due_at DESC LIMIT ?2"
881 ))
882 .bind(&[live_since.as_str().into(), RUN_BACKFILL.into()])?
883 .all()
884 .await?
885 .results::<DueRow>()?;
886 let mut budget = Budget { calls: RUN_AI_CALLS, without: HashSet::new(), paths: HashMap::new() };
887 let mut settled = 0;
888 for due in live.into_iter().chain(old) {
889 let id = due.id.clone();
890 match self.settle_title(due, &mut budget).await {
891 Ok(true) => settled += 1,
892 Ok(false) => {}
893 Err(error) => {
894 worker::console_error!("session {id}: its title was not settled: {error}");
895 // Not tried again at once: the next settle is the next change.
896 let _ = self.db.prepare("UPDATE sessions SET title_due_at = NULL WHERE id = ?").bind(&[id.as_str().into()])?.run().await;
897 }
898 }
899 }
900 Ok(settled)
901 }
902
903 async fn repo_path_of(&self, repo_id: &str, budget: &mut Budget) -> Result<Option<RepoPath>> {
904 if let Some(path) = budget.paths.get(repo_id) {
905 return Ok(path.clone());
906 }
907 let path: Option<RepoPath> = g1t_kit::call(&self.repos, "path_by_id", &PathByIdArgs { id: repo_id.to_owned() }).await?;
908 budget.paths.insert(repo_id.to_owned(), path.clone());
909 Ok(path)
910 }
911
912 /// What a session did, for naming it.
913 async fn title_record(&self, row: &SessionRow, path: Option<&RepoPath>) -> Result<Record> {
914 let now = now_ms();
915 let session = row.clone().at(now);
916 let first = self
917 .db
918 .prepare("SELECT text FROM session_entries WHERE session_id = ? AND kind = 'prompt' ORDER BY session_seq LIMIT 3")
919 .bind(&[row.id.as_str().into()])?
920 .all()
921 .await?
922 .results::<TextRow>()?;
923 let last = self
924 .db
925 .prepare("SELECT text FROM session_entries WHERE session_id = ? AND kind = 'prompt' ORDER BY session_seq DESC LIMIT 3")
926 .bind(&[row.id.as_str().into()])?
927 .all()
928 .await?
929 .results::<TextRow>()?;
930 // As recorded, credentials out (again, for what was recorded before they were taken out as it was).
931 let mut prompts: Vec<String> = first.into_iter().map(|row| shown::clean(&row.text)).collect();
932 for text in last.into_iter().rev().map(|row| shown::clean(&row.text)) {
933 if !prompts.contains(&text) {
934 prompts.push(text);
935 }
936 }
937 let children = self.subagents_of(&row.id, now).await?;
938 let mut ids = vec![row.id.clone()];
939 ids.extend(children.iter().map(|child| child.id.clone()));
940 let (touched, links, _) = self.tree_facts(&row.repo_id, &ids).await?;
941 let tied = |link: &SessionLink| Tied {
942 number: link.number,
943 title: link.title.clone(),
944 strong: link.via.iter().any(|via| *via != LinkVia::Ref),
945 };
946 let files: Vec<String> = touched.iter().filter(|touch| touch.kind == TouchKind::File).map(|touch| touch.value.clone()).collect();
947 Ok(Record {
948 repo: path.map(|path| format!("{}/{}", path.namespace, path.name)).unwrap_or_default(),
949 item: session.item,
950 pull: session.pull,
951 items: links.iter().filter(|link| link.kind == LinkKind::Item).map(tied).collect(),
952 pulls: links.iter().filter(|link| link.kind == LinkKind::Pull).map(tied).collect(),
953 prompts,
954 tasks: children.iter().filter(|child| child.parent_id.as_deref() == Some(row.id.as_str())).filter_map(|child| child.task.clone()).collect(),
955 areas: areas(&files),
956 commits: touched.iter().filter(|touch| touch.kind == TouchKind::Commit).count(),
957 result: session.result.as_deref().map(shown::clean),
958 title: session.title.clone(),
959 })
960 }
961
962 /// The workspace's AI provider's title for a record, if it has one
963 /// and answers with one.
964 async fn asked_title(&self, workspace: &str, record: &Record, budget: &mut Budget) -> Option<String> {
965 if budget.calls == 0 || budget.without.contains(workspace) {
966 return None;
967 }
968 let integrations = self.integrations.as_ref()?;
969 budget.calls -= 1;
970 let args = AiBriefArgs { workspace: workspace.to_owned(), system: SYSTEM.to_owned(), input: digest(record), max_tokens: 40 };
971 let answer: Result<Outcome<Option<String>>> = g1t_kit::call(integrations, "ai_brief", &args).await;
972 match answer {
973 Ok(Outcome::Ok(Some(answer))) => {
974 let title = ai_title(&answer);
975 if title.is_none() {
976 worker::console_log!("{workspace}: the AI provider's title was not one: {}", clipped(&answer, 120));
977 }
978 title
979 }
980 Ok(Outcome::Ok(None)) => {
981 budget.without.insert(workspace.to_owned());
982 None
983 }
984 Ok(Outcome::Fail(failure)) => {
985 worker::console_error!("{workspace}: no title from the AI provider: {}", failure.message);
986 budget.without.insert(workspace.to_owned());
987 None
988 }
989 Err(error) => {
990 worker::console_error!("{workspace}: no title from the AI provider: {error}");
991 budget.without.insert(workspace.to_owned());
992 None
993 }
994 }
995 }
996
997 /// Settles one due title. Whether it changed.
998 async fn settle_title(&self, due: DueRow, budget: &mut Budget) -> Result<bool> {
999 let Some(row) = self.session_row(&due.repo_id, &due.id).await? else {
1000 return Ok(false);
1001 };
1002 let now = now_ms();
1003 let at = rfc3339(now);
1004 let ended = row.state == "ended";
1005 let mut source = row.title_source.as_deref().and_then(TitleSource::parse);
1006 let path = self.repo_path_of(&row.repo_id, budget).await?;
1007 let record = self.title_record(&row, path.as_ref()).await?;
1008 // From before titles said where they came from: the first prompt's
1009 // intent is provisional, any other title its agent's.
1010 if source.is_none()
1011 && let Some(title) = &row.title
1012 {
1013 let first = record.prompts.first().and_then(|prompt| intent_title(prompt));
1014 source = Some(if first.as_deref().is_some_and(|first| same_words(first, title)) { TitleSource::Prompt } else { TitleSource::Agent });
1015 }
1016 let stale = source == Some(TitleSource::Agent)
1017 && ended
1018 && match &row.title_settled_at {
1019 Some(named) => {
1020 self.db
1021 .prepare("SELECT COUNT(*) AS n FROM session_links WHERE session_id = ? AND via <> 'ref' AND at > ?")
1022 .bind(&[row.id.as_str().into(), named.as_str().into()])?
1023 .first::<CountRow>(None)
1024 .await?
1025 .is_some_and(|count| count.n > 0)
1026 }
1027 None => false,
1028 };
1029 let state = Due {
1030 title: row.title.clone(),
1031 source,
1032 ended,
1033 stale,
1034 basis: due.title_basis.clone(),
1035 prompts: due.prompts,
1036 settled_prompts: due.title_prompts,
1037 settled_before: row.title_settled_at.is_some(),
1038 };
1039 let mut asked = 0;
1040 let candidate = if needs_naming(&state, &record) {
1041 let ask = !record.one_thing() && due.title_calls < MAX_AI_CALLS && (!record.prompts.is_empty() || !record.items.is_empty() || !record.pulls.is_empty());
1042 let workspace = path.as_ref().map(|path| path.namespace.to_lowercase()).unwrap_or_default();
1043 let ai = if ask && !workspace.is_empty() {
1044 let before = budget.calls;
1045 let title = self.asked_title(&workspace, &record, budget).await;
1046 asked = before - budget.calls;
1047 title.map(|title| (title, TitleSource::Summary))
1048 } else {
1049 None
1050 };
1051 ai.or_else(|| fallback(&record))
1052 } else {
1053 None
1054 };
1055 let decided = decide(&state, &record, candidate);
1056 let (title, source) = match &decided {
1057 Settled::Keep { source } => (None, *source),
1058 Settled::Retitle { title, source } => (Some(shown::clean(title)), Some(*source)),
1059 };
1060 // Its prompts as they are start the count to the next settle.
1061 let settled_prompts = due.prompts;
1062 let source_text = source.map(TitleSource::as_str);
1063 let settled_at = if title.is_some() || row.title_settled_at.is_none() { at.clone() } else { row.title_settled_at.clone().unwrap_or(at.clone()) };
1064 self.db
1065 .prepare(SETTLE)
1066 .bind(&[
1067 row.id.as_str().into(),
1068 title.as_deref().map_or(worker::wasm_bindgen::JsValue::NULL, Into::into),
1069 source_text.map_or(worker::wasm_bindgen::JsValue::NULL, Into::into),
1070 settled_at.as_str().into(),
1071 u32::from(ended).into(),
1072 record.basis().as_str().into(),
1073 settled_prompts.into(),
1074 asked.into(),
1075 ])?
1076 .run()
1077 .await?;
1078 if title.is_none() {
1079 return Ok(false);
1080 }
1081 // The pages showing it follow along.
1082 self.publish_as(
1083 "session.progressed",
1084 &row.repo_id,
1085 Some(row.actor_id.clone()),
1086 SessionProgressed { repo_id: row.repo_id.clone(), number: row.pull.or(row.item), session_id: row.id.clone(), root_id: None },
1087 )
1088 .await?;
1089 Ok(true)
1090 }
1091}
1092
1093#[cfg(test)]
1094mod tests {
1095 use super::*;
1096
1097 #[test]
1098 fn a_prompts_intent_is_its_first_lines_first_sentence() {
1099 assert_eq!(intent_title("fix the login redirect. It loops.").as_deref(), Some("Fix the login redirect"));
1100 assert_eq!(intent_title("/clear"), None);
1101 assert_eq!(intent_title("<command-name>/model</command-name>"), None);
1102 assert_eq!(intent_title("<ide_opened_file>a.rs</ide_opened_file>\nship the runner setup").as_deref(), Some("Ship the runner setup"));
1103 assert_eq!(
1104 intent_title("<pasted_content id=\"5c50\">\nlog\n</pasted_content>\nship the runner setup").as_deref(),
1105 Some("Ship the runner setup")
1106 );
1107 assert_eq!(intent_title("## Plan\n- **move** the [docs](https://x) to `docs/`").as_deref(), Some("Plan"));
1108 assert_eq!(intent_title("```\ncode\n```\nrename the crate [Image #1]").as_deref(), Some("Rename the crate"));
1109 let long = intent_title(&"word ".repeat(40)).unwrap();
1110 assert!(long.ends_with('…') && long.chars().count() <= TITLE_CHARS, "{long}");
1111 // As the Claude Code plugin read them when it titled sessions itself.
1112 assert_eq!(intent_title("Make preview builds faster. They time out on big repositories.").as_deref(), Some("Make preview builds faster"));
1113 assert_eq!(intent_title("Why does `src/app.ts` fail? It worked yesterday.").as_deref(), Some("Why does src/app.ts fail?"));
1114 assert_eq!(intent_title("```js\nconst a = 1;\n```\n## Clean **this** up\nmore").as_deref(), Some("Clean this up"));
1115 assert_eq!(intent_title("\n\n- look at [the docs](https://docs.g1t.sh) for g1t.mjs").as_deref(), Some("Look at the docs for g1t.mjs"));
1116 assert_eq!(intent_title(" spread over\tspaces ").as_deref(), Some("Spread over spaces"));
1117 let tidy = "[TEST] Tidy up the README and then look at the CI pipeline, which keeps failing on the Windows runners";
1118 let clipped = intent_title(tidy).unwrap();
1119 assert!(clipped.ends_with('…') && !clipped.ends_with(" …") && tidy.starts_with(clipped.trim_end_matches('…')), "{clipped}");
1120 assert_eq!(
1121 intent_title("<pasted_content id=\"5c50\">\nUncaught TypeError: x is undefined\n</pasted_content>\nAlso you broke the buttons").as_deref(),
1122 Some("Also you broke the buttons")
1123 );
1124 assert_eq!(intent_title("[Image #1] the PR link is missing on Home").as_deref(), Some("The PR link is missing on Home"));
1125 assert_eq!(intent_title("[Image: source: C:\\tmp\\1.png]\nmake it render").as_deref(), Some("Make it render"));
1126 assert_eq!(intent_title("<pasted_content id=\"1\">only a paste"), None);
1127 assert_eq!(
1128 intent_title("<ide_opened_file>The user opened the file c:\\a\\Dockerfile in the IDE.</ide_opened_file>\nWhether it's the desktop app or the page, it crashes").as_deref(),
1129 Some("Whether it's the desktop app or the page, it crashes")
1130 );
1131 assert_eq!(intent_title("<ide_selection>const a = 1;</ide_selection>\n<ide_opened_file>a.ts</ide_opened_file>\nrename this").as_deref(), Some("Rename this"));
1132 assert_eq!(intent_title("<ide_opened_file>a.ts</ide_opened_file>\n/clear"), None);
1133 for prompt in ["/clear", "/compact keep the plan", "/g1t:status 4", "<command-name>/clear</command-name>", "<local-command-stdout lang=\"en\">ok</local-command-stdout>", "```\ncode only\n```", "?", ""] {
1134 assert_eq!(intent_title(prompt), None, "{prompt}");
1135 }
1136 }
1137
1138 #[test]
1139 fn a_cleaned_intent_skips_preambles_and_hedges() {
1140 assert_eq!(
1141 cleaned_intent("This project used to be in syntaqx/g1t, but moved it finally in my directory. Migrate every remote and CI config to flagon-io/g1t.").as_deref(),
1142 Some("Migrate every remote and CI config to flagon-io/g1t")
1143 );
1144 assert_eq!(cleaned_intent("So I think we should add retries to the webhook sender").as_deref(), Some("Add retries to the webhook sender"));
1145 assert_eq!(cleaned_intent("Hey! Can you please fix the flaky queue test?").as_deref(), Some("Fix the flaky queue test"));
1146 assert_eq!(cleaned_intent("ok, now rename the crate").as_deref(), Some("Rename the crate"));
1147 assert_eq!(cleaned_intent("/compact"), None);
1148 }
1149
1150 #[test]
1151 fn the_telling_prompt_is_not_one_that_steers() {
1152 let prompts: Vec<String> = ["continue", "This repo is a monorepo. Add a sessions view to Work.", "yes", "commit it"].map(String::from).to_vec();
1153 assert_eq!(telling_prompt(&prompts), Some("This repo is a monorepo. Add a sessions view to Work."));
1154 let prompts: Vec<String> = ["Fix the deploy script", "Also fix the readme"].map(String::from).to_vec();
1155 assert_eq!(telling_prompt(&prompts), Some("Fix the deploy script"));
1156 }
1157
1158 #[test]
1159 fn an_ai_answer_is_a_title_only_when_it_reads_as_one() {
1160 assert_eq!(ai_title("\"Move sessions into Work.\"").as_deref(), Some("Move sessions into Work"));
1161 assert_eq!(ai_title("Title: settle session titles server-side").as_deref(), Some("Settle session titles server-side"));
1162 assert_eq!(ai_title("**Repository migration to flagon-io**\n\nThis session…").as_deref(), Some("Repository migration to flagon-io"));
1163 assert_eq!(ai_title("Done"), None);
1164 assert_eq!(ai_title(&"long ".repeat(20)), None);
1165 assert_eq!(ai_title(""), None);
1166 }
1167
1168 #[test]
1169 fn similar_titles_say_the_same_in_other_words() {
1170 assert!(similar("Settle session titles", "Settling the session titles"));
1171 assert!(similar("Move issues to Work items", "Move issues into work items"));
1172 assert!(!similar("Fix the login redirect", "Migrate repositories to flagon-io"));
1173 assert!(same_words("Fix the login redirect.", "fix the Login redirect"));
1174 assert!(!same_words("Fix login", "Fix the login"));
1175 }
1176
1177 fn tied(number: u32, title: &str, strong: bool) -> Tied {
1178 Tied { number, title: title.into(), strong }
1179 }
1180
1181 #[test]
1182 fn the_fallback_is_the_item_then_the_pull_then_the_prompt() {
1183 let mut record = Record {
1184 prompts: vec!["This project used to be in syntaqx/g1t. Move it to flagon-io and fix every remote.".into()],
1185 ..Record::default()
1186 };
1187 assert_eq!(fallback(&record), Some(("Move it to flagon-io and fix every remote".into(), TitleSource::Summary)));
1188 record.pulls = vec![tied(12, "Point remotes at flagon-io", true)];
1189 assert_eq!(fallback(&record), Some(("Point remotes at flagon-io".into(), TitleSource::Pull)));
1190 // Named in passing only: not what it works on.
1191 record.items = vec![tied(4, "Old bug", false)];
1192 assert_eq!(fallback(&record).unwrap().1, TitleSource::Pull);
1193 record.items.push(tied(57, "Move issues to workspace-level Work items", true));
1194 assert_eq!(fallback(&record), Some(("Move issues to workspace-level Work items".into(), TitleSource::Item)));
1195 // The one it says it is on comes first.
1196 record.items.push(tied(60, "Session titles that settle", true));
1197 record.item = Some(60);
1198 assert_eq!(fallback(&record).unwrap().0, "Session titles that settle");
1199 assert_eq!(record.basis(), "i57,i60,p12");
1200 assert!(!record.one_thing());
1201 let none = Record { tasks: vec!["find the endpoints".into()], ..Record::default() };
1202 assert_eq!(fallback(&none), Some(("Find the endpoints".into(), TitleSource::Summary)));
1203 assert_eq!(fallback(&Record::default()), None);
1204 }
1205
1206 fn due(title: &str, source: Option<TitleSource>) -> Due {
1207 Due {
1208 title: Some(title.into()),
1209 source,
1210 ended: false,
1211 stale: false,
1212 basis: None,
1213 prompts: 3,
1214 settled_prompts: 0,
1215 settled_before: false,
1216 }
1217 }
1218
1219 #[test]
1220 fn a_pinned_title_is_never_replaced() {
1221 let record = Record { items: vec![tied(1, "Something else", true)], ..Record::default() };
1222 let pinned = Due { ended: true, stale: true, ..due("Mine", Some(TitleSource::Person)) };
1223 assert_eq!(decide(&pinned, &record, Some(("Something else".into(), TitleSource::Item))), Settled::Keep { source: Some(TitleSource::Person) });
1224 }
1225
1226 #[test]
1227 fn a_provisional_title_is_replaced_once_there_is_signal() {
1228 let record = Record { items: vec![tied(57, "Move issues to Work items", true)], ..Record::default() };
1229 let provisional = due("This project used to be in syntaqx/g1t, but moved it finally in my directory…", Some(TitleSource::Prompt));
1230 assert_eq!(
1231 decide(&provisional, &record, fallback(&record)),
1232 Settled::Retitle { title: "Move issues to Work items".into(), source: TitleSource::Item }
1233 );
1234 // The same words again: kept, settled.
1235 let same = due("Move issues to work items", Some(TitleSource::Prompt));
1236 assert_eq!(decide(&same, &record, fallback(&record)), Settled::Keep { source: Some(TitleSource::Item) });
1237 }
1238
1239 #[test]
1240 fn a_settled_title_changes_only_when_the_scope_does_and_says_something_else() {
1241 let record = Record { items: vec![tied(57, "Move issues to Work items", true)], ..Record::default() };
1242 let settled = Due { basis: Some("i57".into()), settled_before: true, settled_prompts: 3, prompts: 5, ..due("Move issues to Work items", Some(TitleSource::Item)) };
1243 // Nothing changed: not named again, not even asked.
1244 assert!(!needs_naming(&settled, &record));
1245 assert_eq!(decide(&settled, &record, Some(("Anything".into(), TitleSource::Summary))), Settled::Keep { source: Some(TitleSource::Item) });
1246 // Its prompts grew past twice as many: named again, but a title in
1247 // other words is not a change.
1248 let grown = Due { prompts: 8, ..settled.clone() };
1249 assert!(needs_naming(&grown, &record));
1250 assert_eq!(decide(&grown, &record, Some(("Moving issues into work items".into(), TitleSource::Summary))), Settled::Keep { source: Some(TitleSource::Item) });
1251 // Another work item: a new title that says more.
1252 let wider = Record { items: vec![tied(57, "Move issues to Work items", true), tied(61, "Sessions inside Work", true)], ..Record::default() };
1253 assert!(needs_naming(&settled, &wider));
1254 assert_eq!(
1255 decide(&settled, &wider, Some(("Work items and the sessions view".into(), TitleSource::Summary))),
1256 Settled::Retitle { title: "Work items and the sessions view".into(), source: TitleSource::Summary }
1257 );
1258 }
1259
1260 #[test]
1261 fn an_agents_title_is_kept_unless_stale_at_the_end() {
1262 let record = Record { items: vec![tied(9, "Other work", true)], ..Record::default() };
1263 let named = due("Fix the queue", Some(TitleSource::Agent));
1264 assert!(!needs_naming(&named, &record));
1265 assert!(!needs_naming(&Due { ended: true, ..named.clone() }, &record));
1266 let stale = Due { ended: true, stale: true, ..named };
1267 assert_eq!(decide(&stale, &record, fallback(&record)), Settled::Retitle { title: "Other work".into(), source: TitleSource::Item });
1268 }
1269
1270 #[test]
1271 fn it_is_finalized_from_everything_as_it_ends() {
1272 let record = Record { items: vec![tied(57, "Move issues to Work items", true)], ..Record::default() };
1273 let settled = Due { basis: Some("i57".into()), settled_before: true, ended: true, ..due("Move issues to Work items", Some(TitleSource::Item)) };
1274 assert!(needs_naming(&settled, &record));
1275 assert_eq!(decide(&settled, &record, fallback(&record)), Settled::Keep { source: Some(TitleSource::Item) });
1276 }
1277
1278 #[test]
1279 fn an_agent_echoing_the_provisional_title_leaves_it_provisional() {
1280 assert_eq!(given_source("Fix the login redirect", Some("Fix the login redirect"), Some(TitleSource::Prompt)), TitleSource::Prompt);
1281 assert_eq!(given_source("Fix the login redirect", Some("Fix the login redirect"), None), TitleSource::Prompt);
1282 assert_eq!(given_source("Rewrite auth", Some("Fix the login redirect"), Some(TitleSource::Prompt)), TitleSource::Agent);
1283 assert_eq!(given_source("Rewrite auth", None, None), TitleSource::Agent);
1284 assert_eq!(given_source("move issues to work items", Some("Move issues to Work items"), Some(TitleSource::Item)), TitleSource::Item);
1285 }
1286
1287 #[test]
1288 fn the_digest_is_bounded_and_says_what_it_did() {
1289 let record = Record {
1290 repo: "acme/web".into(),
1291 items: vec![tied(57, "Move issues to Work items", true), tied(3, "Old", false)],
1292 prompts: vec!["x".repeat(5_000), "/clear".into(), "add the sessions view".into()],
1293 tasks: vec!["Find endpoints".into()],
1294 areas: areas(&["services/work/src/a.rs".into(), "services/work/src/b.rs".into(), "README.md".into()]),
1295 commits: 4,
1296 result: Some("Done.".into()),
1297 ..Record::default()
1298 };
1299 let text = digest(&record);
1300 assert!(text.contains("#57 Move issues to Work items"));
1301 assert!(text.contains("mentioned:\n- #3 Old"));
1302 assert!(text.contains("services/work (2)"));
1303 assert!(text.contains("Commits: 4"));
1304 assert!(!text.contains("/clear"));
1305 assert!(text.chars().count() <= MAX_BRIEF_INPUT_CHARS);
1306 assert!(text.contains(&format!("1. {}…", "x".repeat(DIGEST_PROMPT_CHARS - 1))));
1307 }
1308}