diff --git a/packages/junior-evals/evals/integration/conversation/lifecycle.eval.ts b/packages/junior-evals/evals/integration/conversation/lifecycle.eval.ts index 7554f5b631..cc3ec101bb 100644 --- a/packages/junior-evals/evals/integration/conversation/lifecycle.eval.ts +++ b/packages/junior-evals/evals/integration/conversation/lifecycle.eval.ts @@ -45,6 +45,37 @@ describe("Lifecycle and Resilience", () => { expect(conversation.title).toMatch(/leak/i); }); + test("when the first message links a tracked issue, name the conversation after the issue", async ({ + agent, + }) => { + // The plugin stands in for a provider that reads the linked issue. + const issueTracker = defineJuniorPlugin({ + manifest: { + name: "eval-issue-tracker", + displayName: "Eval Issue Tracker", + description: "Eval issue titles", + }, + hooks: { + conversationTitleContext: ({ text }) => + text.includes("tracker.example.test/acme/app/issues/1583") + ? ["acme/app#1583: Checkout button freezes on Safari"] + : undefined, + }, + }); + const { run } = await agent({ + plugins: defineJuniorPlugins([issueTracker]), + }); + + const conversation = await run( + slackMention( + "Fix this https://tracker.example.test/acme/app/issues/1583. For now, reply with exactly: on it", + ), + ); + + expect(conversation.title).toMatch(/1583/); + expect(conversation.title).toMatch(/safari|checkout/i); + }); + // A Slack turn compacts history before the agent starts. A web turn // compacts it in the agent, before the first model request. test.for([ diff --git a/packages/junior-github/src/conversation-title-context.ts b/packages/junior-github/src/conversation-title-context.ts new file mode 100644 index 0000000000..b641b0886b --- /dev/null +++ b/packages/junior-github/src/conversation-title-context.ts @@ -0,0 +1,230 @@ +import type { PluginLogger } from "@sentry/junior-plugin-api"; +import { isRecord } from "./credential-support.js"; + +// Name a Conversation from its main objects, not from every reference it has. +const MAX_REFERENCES = 3; +const TITLE_MAX_LENGTH = 160; + +const OWNER = String.raw`([A-Za-z0-9][A-Za-z0-9-]*)`; +const REPO = String.raw`([\w.-]*\w)`; +const SHA = String.raw`([0-9a-f]{7,40})`; +const NOT_AFTER_PATH = String.raw`(?; +}; + +const repository = (owner?: string, name?: string): Repository | undefined => + owner && name ? { owner, name } : undefined; + +// GitHub autolinked references, plus prose such as "issue 12 on owner/repo". +// https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/autolinked-references-and-urls +const REFERENCE_PATTERNS: ReferencePattern[] = [ + { + pattern: new RegExp( + String.raw`https?:\/\/(?:www\.)?github\.com\/${OWNER}\/${REPO}\/(issues|pull)\/(\d+)`, + "gi", + ), + read: ([, owner, name, path, id]) => ({ + id: id!, + kind: path!.toLowerCase() === "pull" ? "pull_request" : "issue", + repository: repository(owner, name), + }), + }, + { + pattern: new RegExp( + String.raw`https?:\/\/(?:www\.)?github\.com\/${OWNER}\/${REPO}\/commit\/${SHA}`, + "gi", + ), + read: ([, owner, name, id]) => ({ + id: id!, + kind: "commit", + repository: repository(owner, name), + }), + }, + { + pattern: new RegExp( + String.raw`${NOT_AFTER_PATH}${OWNER}\/${REPO}#(\d+)\b`, + "g", + ), + read: ([, owner, name, id]) => ({ + id: id!, + kind: "issue", + repository: repository(owner, name), + }), + }, + { + pattern: new RegExp( + String.raw`${NOT_AFTER_PATH}${OWNER}\/${REPO}@${SHA}\b`, + "g", + ), + read: ([, owner, name, id]) => ({ + id: id!, + kind: "commit", + repository: repository(owner, name), + }), + }, + { + pattern: new RegExp( + String.raw`\b(issues?|bugs?|tickets?|prs?|pulls?|pull requests?)\s+(?:#|no\.?\s*|number\s+)?(\d+)\b(?:\s+${REPO_IN_PROSE})?`, + "gi", + ), + read: ([, noun, id, owner, name]) => ({ + id: id!, + kind: /^(?:prs?|pulls?|pull requests?)$/i.test(noun!) + ? "pull_request" + : "issue", + repository: repository(owner, name), + }), + }, + { + pattern: /(? ({ id: id!, kind: "issue" }), + }, +]; + +/** Read `repo` as `owner/name`, or as `name` with the `org` config value. */ +function configuredRepository( + config: Record, +): Repository | undefined { + const [first, second, ...extra] = (config.repo ?? "").trim().split("/"); + if (extra.length > 0) return undefined; + return second + ? repository(first, second) + : repository(config.org?.trim(), first); +} + +/** + * Find distinct GitHub references in message text, in message order. + * A reference without a repository uses the one repository that the message + * names, or else the configured repository. + */ +function parseGitHubReferences( + text: string, + config: Record, +): GitHubReference[] { + const matches = REFERENCE_PATTERNS.flatMap(({ pattern, read }) => + [...text.matchAll(pattern)].map((match) => ({ + ...read(match), + end: match.index + match[0].length, + start: match.index, + })), + ).sort((left, right) => left.start - right.start || right.end - left.end); + + const accepted: ReferenceMatch[] = []; + for (const match of matches) { + const previous = accepted.at(-1); + if (!previous || match.start >= previous.end) accepted.push(match); + } + + const namedRepositories = new Map(); + for (const match of accepted) { + if (match.repository) { + const key = `${match.repository.owner}/${match.repository.name}`; + namedRepositories.set(key.toLowerCase(), match.repository); + } + } + for (const match of text.matchAll(new RegExp(REPO_IN_PROSE, "gi"))) { + const named = repository(match[1], match[2]); + if (named) { + namedRepositories.set( + `${named.owner}/${named.name}`.toLowerCase(), + named, + ); + } + } + const fallback = + namedRepositories.size === 1 + ? [...namedRepositories.values()][0] + : configuredRepository(config); + + const references = new Map(); + for (const match of accepted) { + const target = match.repository ?? fallback; + if (!target) continue; + const key = + `${target.owner}/${target.name}${match.kind === "commit" ? "@" : "#"}${match.id}`.toLowerCase(); + if (references.has(key)) continue; + references.set(key, { ...target, id: match.id, kind: match.kind }); + if (references.size >= MAX_REFERENCES) break; + } + return [...references.values()]; +} + +/** Read the display title of an issue, pull request, or commit response. */ +function objectTitle( + kind: GitHubReference["kind"], + object: unknown, +): string | undefined { + if (!isRecord(object)) return undefined; + const raw = + kind === "commit" + ? isRecord(object.commit) && typeof object.commit.message === "string" + ? object.commit.message.split("\n")[0] + : undefined + : typeof object.title === "string" + ? object.title + : undefined; + return raw?.replace(/\s+/g, " ").trim() || undefined; +} + +/** + * Describe GitHub issues, pull requests, and commits that message text + * references, as `owner/repo#123: Title` or `owner/repo@abc1234: Subject`. + * References that `loadObject` cannot read are left out. + */ +export async function loadGitHubConversationTitleContext(args: { + config: Record; + loadObject(reference: GitHubReference): Promise; + log: PluginLogger; + text: string; +}): Promise { + const lines = await Promise.all( + parseGitHubReferences(args.text, args.config).map(async (reference) => { + try { + const title = objectTitle( + reference.kind, + await args.loadObject(reference), + ); + if (!title) return undefined; + const label = + reference.kind === "commit" + ? `${reference.owner}/${reference.name}@${reference.id.slice(0, 7)}` + : `${reference.owner}/${reference.name}#${reference.id}`; + return `${label}: ${title.slice(0, TITLE_MAX_LENGTH)}`; + } catch (error) { + args.log.warn("github.conversation_title_context.load_failed", { + "app.github.object_kind": reference.kind, + "exception.message": + error instanceof Error ? error.message : String(error), + }); + return undefined; + } + }), + ); + return lines.filter((line): line is string => Boolean(line)); +} diff --git a/packages/junior-github/src/plugin.ts b/packages/junior-github/src/plugin.ts index c2d9b6cc04..78b2b10b0a 100644 --- a/packages/junior-github/src/plugin.ts +++ b/packages/junior-github/src/plugin.ts @@ -57,6 +57,7 @@ import { prepareCommitMsgHook, } from "./git-config.js"; import { linkifyGitHubReferences } from "./reply-markdown.js"; +import { loadGitHubConversationTitleContext } from "./conversation-title-context.js"; import { prepareWorkspace } from "./workspace-prepare.js"; import { githubGrantForEgress, @@ -246,6 +247,49 @@ export function githubPlugin( ], }, hooks: { + async conversationTitleContext(ctx) { + return await loadGitHubConversationTitleContext({ + config: ctx.config, + log: ctx.log, + text: ctx.text, + loadObject: async (reference) => { + const { collection, operation } = ( + { + commit: { + collection: "commits", + operation: "github.commit.get", + }, + issue: { collection: "issues", operation: "github.issue.get" }, + pull_request: { + collection: "pulls", + operation: "github.pull.get", + }, + } as const + )[reference.kind]; + // Read through host egress so policy, grants, and audit apply. + const response = await ctx.egress.fetch({ + provider: "github", + operation, + request: new Request( + `https://api.github.com/repos/${encodeURIComponent(reference.owner)}/${encodeURIComponent(reference.name)}/${collection}/${encodeURIComponent(reference.id)}`, + { + headers: { + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + signal: ctx.signal, + }, + ), + }); + if (!response.ok) { + throw new Error( + `GitHub ${reference.kind} lookup failed with HTTP ${response.status}`, + ); + } + return await response.json(); + }, + }); + }, conversationSidebar(ctx) { return { annotationsByConversationId: Object.fromEntries( diff --git a/packages/junior-github/tests/conversation-title-context.test.ts b/packages/junior-github/tests/conversation-title-context.test.ts new file mode 100644 index 0000000000..134191fe61 --- /dev/null +++ b/packages/junior-github/tests/conversation-title-context.test.ts @@ -0,0 +1,166 @@ +import type { PluginEgress } from "@sentry/junior-plugin-api"; +import { describe, expect, it, vi } from "vitest"; +import { + type GitHubReference, + loadGitHubConversationTitleContext, +} from "../src/conversation-title-context"; +import { githubPlugin } from "../src/index"; + +const log = { error: vi.fn(), info: vi.fn(), warn: vi.fn() }; + +/** Return the references that title context reads for one message. */ +async function readReferences( + text: string, + config: Record = {}, +): Promise { + const loadObject = vi.fn(async () => ({ title: "Title" })); + await loadGitHubConversationTitleContext({ config, loadObject, log, text }); + return loadObject.mock.calls.map(([reference]) => reference); +} + +describe("GitHub conversation title context", () => { + it.each([ + [ + "Fix this ", + { kind: "issue", id: "1583" }, + ], + [ + "see https://github.com/getsentry/junior/pull/42/files", + { kind: "pull_request", id: "42" }, + ], + [ + "why did https://github.com/getsentry/junior/commit/a5c3785ed8d6a35868bc169f07e40e889087fd2e break", + { kind: "commit", id: "a5c3785ed8d6a35868bc169f07e40e889087fd2e" }, + ], + ["Fix getsentry/junior#1583", { kind: "issue", id: "1583" }], + ["Revert getsentry/junior@a5c3785", { kind: "commit", id: "a5c3785" }], + ["Let's fix issue 1583 on getsentry/junior", { kind: "issue", id: "1583" }], + [ + "Can you review pull request #42 in getsentry/junior?", + { kind: "pull_request", id: "42" }, + ], + ])("reads the reference in %j", async (text, reference) => { + await expect(readReferences(text)).resolves.toEqual([ + { owner: "getsentry", name: "junior", ...reference }, + ]); + }); + + it("gives bare references the named or configured repository", async () => { + // One repository named in the message wins over the config. + await expect( + readReferences("Fix #12 and GH-13 from getsentry/sentry", { + repo: "getsentry/junior", + }), + ).resolves.toEqual([ + { owner: "getsentry", name: "sentry", kind: "issue", id: "12" }, + { owner: "getsentry", name: "sentry", kind: "issue", id: "13" }, + ]); + await expect( + readReferences("Let's get PR 1234 fixed up", { + org: "getsentry", + repo: "junior", + }), + ).resolves.toEqual([ + { owner: "getsentry", name: "junior", kind: "pull_request", id: "1234" }, + ]); + // Without a repository, a bare number is not a reference. + await expect(readReferences("Fix #12 and PR 13")).resolves.toEqual([]); + }); + + it("reads each distinct reference once, up to three", async () => { + await expect( + readReferences( + [ + "Fix https://github.com/getsentry/junior/issues/1", + "again getsentry/junior#1 and #2, issue 3, PR 4", + "and https://github.com/getsentry/junior/blob/main/README.md", + "with  and https://example.com/a#6", + ].join("\n"), + { repo: "getsentry/junior" }, + ), + ).resolves.toEqual([ + { owner: "getsentry", name: "junior", kind: "issue", id: "1" }, + { owner: "getsentry", name: "junior", kind: "issue", id: "2" }, + { owner: "getsentry", name: "junior", kind: "issue", id: "3" }, + ]); + }); + + it("describes readable objects and leaves out failed reads", async () => { + const loadObject = vi.fn(async (reference: GitHubReference) => { + if (reference.id === "7") throw new Error("Not Found"); + return reference.kind === "commit" + ? { commit: { message: "Fix title model\n\nLonger body." } } + : { title: " Conversation titles ignore issue titles " }; + }); + + await expect( + loadGitHubConversationTitleContext({ + config: {}, + loadObject, + log, + text: "Fix getsentry/junior#1583, getsentry/private#7, and getsentry/junior@a5c3785ed8d6", + }), + ).resolves.toEqual([ + "getsentry/junior#1583: Conversation titles ignore issue titles", + "getsentry/junior@a5c3785: Fix title model", + ]); + expect(log.warn).toHaveBeenCalledWith( + "github.conversation_title_context.load_failed", + expect.objectContaining({ "exception.message": "Not Found" }), + ); + }); + + it("reads objects only through host egress", async () => { + // A direct GitHub request here would skip egress policy and audit. + const fetchSpy = vi.spyOn(globalThis, "fetch"); + const egress: PluginEgress = { + fetch: vi.fn(async ({ request }) => + Response.json( + request.url.endsWith("/pulls/42") + ? { title: "Add title context" } + : { title: "Titles ignore issues" }, + ), + ), + }; + + await expect( + githubPlugin().hooks?.conversationTitleContext?.({ + config: { repo: "getsentry/junior" }, + db: {}, + egress, + log, + plugin: { name: "github" }, + signal: new AbortController().signal, + text: "Fix #1583 with PR 42", + }), + ).resolves.toEqual([ + "getsentry/junior#1583: Titles ignore issues", + "getsentry/junior#42: Add title context", + ]); + expect( + vi + .mocked(egress.fetch) + .mock.calls.map(([input]) => [ + input.provider, + input.operation, + input.request.method, + input.request.url, + ]), + ).toEqual([ + [ + "github", + "github.issue.get", + "GET", + "https://api.github.com/repos/getsentry/junior/issues/1583", + ], + [ + "github", + "github.pull.get", + "GET", + "https://api.github.com/repos/getsentry/junior/pulls/42", + ], + ]); + expect(fetchSpy).not.toHaveBeenCalled(); + fetchSpy.mockRestore(); + }); +}); diff --git a/packages/junior-plugin-api/README.md b/packages/junior-plugin-api/README.md index 3943e23421..da89f47fbd 100644 --- a/packages/junior-plugin-api/README.md +++ b/packages/junior-plugin-api/README.md @@ -48,6 +48,12 @@ reports, and other typed hook surfaces exported by this package. - Prompt hooks return bounded structured prompt messages rather than mutate the core prompt. - `formatMarkdown` is a pure text rewrite into ordinary Markdown before delivery. +- `conversationTitleContext` returns short facts about objects that the first + human message refers to, such as an issue number and title. The hook gets the + plugin's config values, such as a default repository. It must read provider + data through `ctx.egress`, which uses Junior's system identity and never + starts authorization. Only the title model sees the facts. Junior bounds the + wait and ignores a failed hook. - User prompt hooks for durable turns may emit registered structured events through `ctx.events` for auxiliary work completed while building context. - Tool hooks return model-visible schemas aligned with their executor inputs. diff --git a/packages/junior-plugin-api/src/hooks.ts b/packages/junior-plugin-api/src/hooks.ts index 8723537b16..79581f04b9 100644 --- a/packages/junior-plugin-api/src/hooks.ts +++ b/packages/junior-plugin-api/src/hooks.ts @@ -1,3 +1,4 @@ +import type { PluginContext } from "./context"; import type { ObjectAnnotation } from "./object-annotations"; import type { ConversationSidebarHookContext, @@ -30,6 +31,7 @@ import type { import type { AfterMcpToolHookContext, BeforeToolExecuteHookContext, + PluginEgress, PluginToolDefinition, SandboxPrepareHookContext, ToolRegistrationHookContext, @@ -48,7 +50,33 @@ export interface FormatMarkdownHookContext { text: string; } +/** First human message of a new Conversation, given before title generation. */ +export interface ConversationTitleContextHookContext extends PluginContext { + /** + * String config values of this plugin for the Conversation's Location, by + * short key. For example, `repo` for the `github.repo` config key. + */ + config: Record; + /** + * Host-owned provider egress for title reads. It uses Junior's system + * identity, so it never uses a user's credentials or starts authorization. + */ + egress: PluginEgress; + /** Ends the hook when the host stops waiting for title context. */ + signal: AbortSignal; + text: string; +} + export interface PluginHooks { + /** + * Return short facts that help name a new Conversation, such as + * `owner/repo#123: Issue title` for a linked issue. Junior shows these facts + * only to the title model. Return nothing when the message has no known + * objects. + */ + conversationTitleContext?( + ctx: ConversationTitleContextHookContext, + ): Promise | string[] | undefined; conversationSidebar?( ctx: ConversationSidebarHookContext, ): Promise | ConversationSidebarResult; diff --git a/packages/junior/src/chat/configuration/sql.ts b/packages/junior/src/chat/configuration/sql.ts index 0ab6609f1d..325d018a6f 100644 --- a/packages/junior/src/chat/configuration/sql.ts +++ b/packages/junior/src/chat/configuration/sql.ts @@ -10,6 +10,7 @@ import { import { configValueSchema } from "@/chat/configuration/types"; import type { ConfigEntry, + ConfigValue, LocationConfigurationService, } from "@/chat/configuration/types"; @@ -169,6 +170,21 @@ function createSqlLocationConfigurationStorage( }; } +/** + * Read SQL Location configuration values by key. This read does not create + * the Location or copy legacy entries, so callers must treat it as best effort. + */ +export async function readLocationConfigurationValues(args: { + db: JuniorDatabase; + destination: SlackDestination; +}): Promise> { + const entries = await createSqlLocationConfigurationStorage( + args.db, + args.destination, + ).list(); + return Object.fromEntries(entries.map((entry) => [entry.key, entry.value])); +} + /** Resolve SQL-owned Location configuration and copy live legacy entries once. */ export function createDurableLocationConfigurationService(args: { destination: SlackDestination; diff --git a/packages/junior/src/chat/plugins/conversation-title-context.ts b/packages/junior/src/chat/plugins/conversation-title-context.ts new file mode 100644 index 0000000000..c60d2c9a68 --- /dev/null +++ b/packages/junior/src/chat/plugins/conversation-title-context.ts @@ -0,0 +1,96 @@ +import { credentialContextForActor } from "@/chat/credentials/context"; +import { getDb } from "@/chat/db"; +import { createPluginEgress } from "@/chat/egress/plugin"; +import { logWarn } from "@/chat/logging"; +import { getPlugins } from "@/chat/plugins/agent-hooks"; +import { createPluginLogger } from "@/chat/plugins/logging"; + +// Title context is optional. A slow provider must not hold the title back. +const TITLE_CONTEXT_TIMEOUT_MS = 5_000; +const TITLE_CONTEXT_MAX_LINES = 5; +const TITLE_CONTEXT_LINE_MAX_LENGTH = 200; +// Title work runs outside the Turn, so it has no requester credentials. Reads +// go through credentialed egress as this system Actor. It cannot use user +// credentials, and a credential that it cannot get only drops the facts. +const CONVERSATION_TITLE_SYSTEM_ACTOR = { + platform: "system", + name: "conversation-title", +} as const; + +/** Pick one plugin's string config values and drop the plugin name prefix. */ +function pluginConfig( + pluginName: string, + configuration: Record, +): Record { + const prefix = `${pluginName}.`; + return Object.fromEntries( + Object.entries(configuration).flatMap(([key, value]) => + key.startsWith(prefix) && typeof value === "string" + ? [[key.slice(prefix.length), value]] + : [], + ), + ); +} + +/** + * Collect plugin facts, such as linked issue titles, for one new + * Conversation title. `configuration` holds config values by full key. + * Fails open: a failed plugin adds no facts. + */ +export async function getPluginConversationTitleContext(args: { + configuration: Record; + text: string; +}): Promise { + const signal = AbortSignal.timeout(TITLE_CONTEXT_TIMEOUT_MS); + const egress = createPluginEgress({ + credentialContext: credentialContextForActor( + CONVERSATION_TITLE_SYSTEM_ACTOR, + ), + // Title context is optional. It must never ask anyone to authorize. + pluginAuth: { async handleAuthRequired() {} }, + }); + // Stop waiting at the deadline even when a hook ignores the signal. + const timedOut = new Promise((_, reject) => { + signal.addEventListener("abort", () => reject(signal.reason), { + once: true, + }); + }); + // The deadline can pass after every hook returns. + timedOut.catch(() => undefined); + const results = await Promise.all( + getPlugins().map(async (plugin) => { + const hook = plugin.hooks?.conversationTitleContext; + if (!hook) return []; + try { + const lines = await Promise.race([ + hook({ + config: pluginConfig(plugin.manifest.name, args.configuration), + db: getDb(), + egress, + log: createPluginLogger(plugin.manifest.name), + plugin: { name: plugin.manifest.name }, + signal, + text: args.text, + }), + timedOut, + ]); + return Array.isArray(lines) ? lines : []; + } catch (error) { + logWarn("plugin.conversation_title_context.hook.failed", { + "app.plugin.name": plugin.manifest.name, + "exception.message": + error instanceof Error ? error.message : String(error), + }); + return []; + } + }), + ); + return results + .flat() + .filter((line): line is string => typeof line === "string") + .map((line) => + line.replace(/\s+/g, " ").trim().slice(0, TITLE_CONTEXT_LINE_MAX_LENGTH), + ) + .filter(Boolean) + .slice(0, TITLE_CONTEXT_MAX_LINES); +} diff --git a/packages/junior/src/chat/services/conversation-title.ts b/packages/junior/src/chat/services/conversation-title.ts index 95731be915..e5bd5627fc 100644 --- a/packages/junior/src/chat/services/conversation-title.ts +++ b/packages/junior/src/chat/services/conversation-title.ts @@ -1,12 +1,16 @@ import type { ConversationStore } from "@/chat/conversations/store"; -import { getConversationStore } from "@/chat/db"; +import { getConfigDefaults } from "@/chat/configuration/defaults"; +import { readLocationConfigurationValues } from "@/chat/configuration/sql"; +import { getConversationStore, getDb } from "@/chat/db"; import { logException, logWarn } from "@/chat/logging"; import { completeText } from "@/chat/pi/client"; +import { getPluginConversationTitleContext } from "@/chat/plugins/conversation-title-context"; import { getThreadTitleSourceMessage } from "@/chat/services/conversation-memory"; import { fallbackShortTitle, generateShortTitle, } from "@/chat/services/short-title"; +import type { Destination } from "@sentry/junior-plugin-api"; import type { ConversationMessage, ThreadConversationState, @@ -62,6 +66,29 @@ function getRunInputMessage( return earliest; } +/** + * Read config values for title context, such as a default repository. A + * failed Location read keeps the install defaults, so the title still works. + */ +async function resolveTitleConfiguration( + destination: Destination | undefined, +): Promise> { + const defaults = getConfigDefaults(); + if (destination?.platform !== "slack") return defaults; + try { + return { + ...defaults, + ...(await readLocationConfigurationValues({ db: getDb(), destination })), + }; + } catch (error) { + logWarn("conversation.title.configuration.failed", { + "exception.message": + error instanceof Error ? error.message : String(error), + }); + return defaults; + } +} + async function ensureConversationTitleOnce(args: { conversation: ThreadConversationState; conversationId: string; @@ -99,7 +126,22 @@ async function ensureConversationTitleOnce(args: { const kind = humanMessage ? "conversation" : "run"; let title: string | undefined; try { - title = await generateShortTitle({ completeText, kind, sourceText }); + // Run input is task boilerplate; only human messages link objects. + const context = + kind === "conversation" + ? await getPluginConversationTitleContext({ + configuration: await resolveTitleConfiguration( + stored?.destination, + ), + text: sourceText, + }) + : undefined; + title = await generateShortTitle({ + completeText, + context, + kind, + sourceText, + }); } catch (error) { logWarn("conversation.title.generation.failed", { "exception.message": diff --git a/packages/junior/src/chat/services/short-title.ts b/packages/junior/src/chat/services/short-title.ts index d67d8fd063..e76d32e982 100644 --- a/packages/junior/src/chat/services/short-title.ts +++ b/packages/junior/src/chat/services/short-title.ts @@ -58,9 +58,14 @@ export function fallbackShortTitle( return normalizeShortTitle(firstLine) || fallback; } -/** Generate a short title with the fast model, or return undefined on failure. */ +/** + * Generate a short title with the fast model, or return undefined on failure. + * `context` holds facts about objects that the source text links, such as + * issue titles. + */ export async function generateShortTitle(args: { completeText: typeof completeText; + context?: string[]; kind: ShortTitleKind; sourceText: string; signal?: AbortSignal; @@ -82,6 +87,17 @@ export async function generateShortTitle(args: { ...TITLE_PROMPTS[args.kind], "", `${SOURCE_LABELS[args.kind]}: ${sourceText.slice(0, SOURCE_TEXT_MAX_LENGTH[args.kind])}`, + // Add this block only with context, so other title requests stay + // the same. + ...(args.context?.length + ? [ + "", + "Linked objects:", + ...args.context.map((line) => `- ${line}`), + "", + "When the message refers to a linked object, name it by its number and title, for example: Fix #123: Login fails on Safari.", + ] + : []), ].join("\n"), timestamp: Date.now(), },