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