From b465feba5f2afa243d2a5187d4ff08f87089b3ee Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:24:56 +0000 Subject: [PATCH 1/4] feat(conversations): Name conversations after linked issues Add a conversationTitleContext plugin hook. Core passes the first human message to plugins and gives the returned facts to the title model under Linked objects. The GitHub plugin reads the titles of linked issues and pull requests with a read-only token scoped to each repository. Co-Authored-By: BYK --- .../conversation/lifecycle.eval.ts | 31 +++++++++ .../src/conversation-title-context.ts | 66 +++++++++++++++++++ .../junior-github/src/credential-support.ts | 2 + packages/junior-github/src/plugin.ts | 27 ++++++++ .../tests/conversation-title-context.test.ts | 53 +++++++++++++++ packages/junior-plugin-api/README.md | 3 + packages/junior-plugin-api/src/hooks.ts | 17 +++++ .../plugins/conversation-title-context.ts | 61 +++++++++++++++++ .../src/chat/services/conversation-title.ts | 13 +++- .../junior/src/chat/services/short-title.ts | 15 ++++- 10 files changed, 286 insertions(+), 2 deletions(-) create mode 100644 packages/junior-github/src/conversation-title-context.ts create mode 100644 packages/junior-github/tests/conversation-title-context.test.ts create mode 100644 packages/junior/src/chat/plugins/conversation-title-context.ts 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..ac045535cf --- /dev/null +++ b/packages/junior-github/src/conversation-title-context.ts @@ -0,0 +1,66 @@ +import type { PluginLogger } from "@sentry/junior-plugin-api"; +import { isRecord } from "./credential-support.js"; + +const GITHUB_OBJECT_URL = + /https:\/\/github\.com\/([\w.-]+)\/([\w.-]+)\/(issues|pull)\/(\d+)/gi; +// Name a Conversation from its main objects, not from every link it lists. +const MAX_LINKED_OBJECTS = 3; +const TITLE_MAX_LENGTH = 160; + +export interface GitHubObjectLink { + kind: "issue" | "pull_request"; + name: string; + number: number; + owner: string; +} + +/** Find distinct GitHub issue and pull request links in message text. */ +function parseGitHubObjectLinks(text: string): GitHubObjectLink[] { + const links = new Map(); + for (const match of text.matchAll(GITHUB_OBJECT_URL)) { + const [, owner, name, path, number] = match; + if (!owner || !name || !path || !number) continue; + const key = `${owner}/${name}#${number}`.toLowerCase(); + if (links.has(key)) continue; + links.set(key, { + kind: path.toLowerCase() === "pull" ? "pull_request" : "issue", + name, + number: Number(number), + owner, + }); + if (links.size >= MAX_LINKED_OBJECTS) break; + } + return [...links.values()]; +} + +/** + * Describe linked GitHub issues and pull requests for the title model, as + * `owner/repo#123: Title`. Links that `loadObject` cannot read are left out. + */ +export async function loadGitHubConversationTitleContext(args: { + loadObject(link: GitHubObjectLink): Promise; + log: PluginLogger; + text: string; +}): Promise { + const lines = await Promise.all( + parseGitHubObjectLinks(args.text).map(async (link) => { + try { + const object = await args.loadObject(link); + const title = + isRecord(object) && typeof object.title === "string" + ? object.title.replace(/\s+/g, " ").trim() + : ""; + if (!title) return undefined; + return `${link.owner}/${link.name}#${link.number}: ${title.slice(0, TITLE_MAX_LENGTH)}`; + } catch (error) { + args.log.warn("github.conversation_title_context.load_failed", { + "app.github.object_kind": link.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/credential-support.ts b/packages/junior-github/src/credential-support.ts index e3cd681a4f..6a3fe2676a 100644 --- a/packages/junior-github/src/credential-support.ts +++ b/packages/junior-github/src/credential-support.ts @@ -40,6 +40,7 @@ export type GitHubGrant = PluginGrant & { interface GitHubRequestParams { body?: unknown; method?: string; + signal?: AbortSignal; token: string; } @@ -276,6 +277,7 @@ export async function githubRequest( ...(params.body ? { "Content-Type": "application/json" } : undefined), }, ...(params.body ? { body: JSON.stringify(params.body) } : undefined), + signal: params.signal, }); const text = await response.text(); diff --git a/packages/junior-github/src/plugin.ts b/packages/junior-github/src/plugin.ts index c2d9b6cc04..ad1216fcac 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,32 @@ export function githubPlugin( ], }, hooks: { + async conversationTitleContext(ctx) { + return await loadGitHubConversationTitleContext({ + log: ctx.log, + text: ctx.text, + loadObject: async (link) => { + // Read one object with a token scoped to its repository. + const token = await issueInstallationToken({ + appIdEnv, + privateKeyEnv, + installationIdEnv, + permissions: + link.kind === "pull_request" + ? { pull_requests: "read" } + : { issues: "read" }, + repositories: [link.name], + }); + const collection = + link.kind === "pull_request" ? "pulls" : "issues"; + return await githubRequest( + "https://api.github.com", + `/repos/${encodeURIComponent(link.owner)}/${encodeURIComponent(link.name)}/${collection}/${link.number}`, + { signal: ctx.signal, token: token.token }, + ); + }, + }); + }, 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..dea40c03f9 --- /dev/null +++ b/packages/junior-github/tests/conversation-title-context.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from "vitest"; +import { loadGitHubConversationTitleContext } from "../src/conversation-title-context"; + +const log = { error: vi.fn(), info: vi.fn(), warn: vi.fn() }; + +describe("GitHub conversation title context", () => { + it("reads each distinct issue and pull request link once", async () => { + const loadObject = vi.fn(async () => ({ title: "Title" })); + + await loadGitHubConversationTitleContext({ + loadObject, + log, + text: [ + "Fix this ", + "again https://github.com/getsentry/junior/issues/1583#issuecomment-1", + "see https://github.com/getsentry/sentry-docs/pull/42/files", + "and https://github.com/getsentry/junior/blob/main/README.md", + ].join("\n"), + }); + + expect(loadObject.mock.calls.map(([link]) => link)).toEqual([ + { kind: "issue", name: "junior", number: 1583, owner: "getsentry" }, + { + kind: "pull_request", + name: "sentry-docs", + number: 42, + owner: "getsentry", + }, + ]); + }); + + it("describes readable objects and leaves out failed reads", async () => { + const loadObject = vi.fn(async (link: { number: number }) => { + if (link.number === 7) throw new Error("Not Found"); + return { title: " Conversation titles ignore issue titles " }; + }); + + await expect( + loadGitHubConversationTitleContext({ + loadObject, + log, + text: "Fix https://github.com/getsentry/junior/issues/1583 and https://github.com/getsentry/private/issues/7", + }), + ).resolves.toEqual([ + "getsentry/junior#1583: Conversation titles ignore issue titles", + ]); + expect(loadObject).toHaveBeenCalledTimes(2); + expect(log.warn).toHaveBeenCalledWith( + "github.conversation_title_context.load_failed", + expect.objectContaining({ "exception.message": "Not Found" }), + ); + }); +}); diff --git a/packages/junior-plugin-api/README.md b/packages/junior-plugin-api/README.md index 3943e23421..7f5a6de74f 100644 --- a/packages/junior-plugin-api/README.md +++ b/packages/junior-plugin-api/README.md @@ -48,6 +48,9 @@ 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 links, such as an issue number and title. Only the title model + sees them. 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..66f3ddd98f 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, @@ -48,7 +49,23 @@ export interface FormatMarkdownHookContext { text: string; } +/** First human message of a new Conversation, given before title generation. */ +export interface ConversationTitleContextHookContext extends PluginContext { + /** 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/plugins/conversation-title-context.ts b/packages/junior/src/chat/plugins/conversation-title-context.ts new file mode 100644 index 0000000000..406d34aee7 --- /dev/null +++ b/packages/junior/src/chat/plugins/conversation-title-context.ts @@ -0,0 +1,61 @@ +import { getDb } from "@/chat/db"; +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; + +/** + * Collect plugin facts, such as linked issue titles, for one new + * Conversation title. Fails open: a failed plugin adds no facts. + */ +export async function getPluginConversationTitleContext( + text: string, +): Promise { + const signal = AbortSignal.timeout(TITLE_CONTEXT_TIMEOUT_MS); + // 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({ + db: getDb(), + log: createPluginLogger(plugin.manifest.name), + plugin: { name: plugin.manifest.name }, + signal, + 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..b632c9fad3 100644 --- a/packages/junior/src/chat/services/conversation-title.ts +++ b/packages/junior/src/chat/services/conversation-title.ts @@ -2,6 +2,7 @@ import type { ConversationStore } from "@/chat/conversations/store"; import { getConversationStore } 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, @@ -99,7 +100,17 @@ 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(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..77b66a50fc 100644 --- a/packages/junior/src/chat/services/short-title.ts +++ b/packages/junior/src/chat/services/short-title.ts @@ -23,6 +23,7 @@ const TITLE_PROMPTS: Record = { conversation: [ "Generate a concise 5-8 word conversation title from the first user message below.", "Capture the user's main request.", + "When the message links an object listed under Linked objects, name it by its number and title, for example: Fix #123: Login fails on Safari.", "Reply with ONLY the title, with no quotes or trailing punctuation.", ], run: [ @@ -58,9 +59,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 +88,13 @@ export async function generateShortTitle(args: { ...TITLE_PROMPTS[args.kind], "", `${SOURCE_LABELS[args.kind]}: ${sourceText.slice(0, SOURCE_TEXT_MAX_LENGTH[args.kind])}`, + ...(args.context?.length + ? [ + "", + "Linked objects:", + ...args.context.map((line) => `- ${line}`), + ] + : []), ].join("\n"), timestamp: Date.now(), }, From 46d360423c017ac18c5ca9e11b0b44efa6eb42ba Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:53:32 +0000 Subject: [PATCH 2/4] feat(github): Read autolinked and prose references for conversation titles Detect issue, pull request, and commit references in the forms that GitHub autolinks, and in prose such as "issue 1583 on getsentry/junior" or "PR 1234". A reference without a repository uses the one repository that the message names, or else the configured github.repo. The title context hook now gets the plugin's config values for the Location. Co-Authored-By: BYK --- .../src/conversation-title-context.ts | 232 +++++++++++++++--- packages/junior-github/src/plugin.ts | 30 ++- .../tests/conversation-title-context.test.ts | 109 ++++++-- packages/junior-plugin-api/README.md | 5 +- packages/junior-plugin-api/src/hooks.ts | 5 + packages/junior/src/chat/configuration/sql.ts | 16 ++ .../plugins/conversation-title-context.ts | 28 ++- .../src/chat/services/conversation-title.ts | 35 ++- 8 files changed, 382 insertions(+), 78 deletions(-) diff --git a/packages/junior-github/src/conversation-title-context.ts b/packages/junior-github/src/conversation-title-context.ts index ac045535cf..b641b0886b 100644 --- a/packages/junior-github/src/conversation-title-context.ts +++ b/packages/junior-github/src/conversation-title-context.ts @@ -1,60 +1,224 @@ import type { PluginLogger } from "@sentry/junior-plugin-api"; import { isRecord } from "./credential-support.js"; -const GITHUB_OBJECT_URL = - /https:\/\/github\.com\/([\w.-]+)\/([\w.-]+)\/(issues|pull)\/(\d+)/gi; -// Name a Conversation from its main objects, not from every link it lists. -const MAX_LINKED_OBJECTS = 3; +// Name a Conversation from its main objects, not from every reference it has. +const MAX_REFERENCES = 3; const TITLE_MAX_LENGTH = 160; -export interface GitHubObjectLink { - kind: "issue" | "pull_request"; +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`(?(); - for (const match of text.matchAll(GITHUB_OBJECT_URL)) { - const [, owner, name, path, number] = match; - if (!owner || !name || !path || !number) continue; - const key = `${owner}/${name}#${number}`.toLowerCase(); - if (links.has(key)) continue; - links.set(key, { - kind: path.toLowerCase() === "pull" ? "pull_request" : "issue", - name, - number: Number(number), - owner, - }); - if (links.size >= MAX_LINKED_OBJECTS) break; +interface Repository { + name: string; + owner: string; +} + +interface ReferenceMatch { + end: number; + id: string; + kind: GitHubReference["kind"]; + repository?: Repository; + start: number; +} + +type ReferencePattern = { + pattern: RegExp; + read(match: RegExpMatchArray): Omit; +}; + +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); } - return [...links.values()]; + + 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 linked GitHub issues and pull requests for the title model, as - * `owner/repo#123: Title`. Links that `loadObject` cannot read are left out. + * 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: { - loadObject(link: GitHubObjectLink): Promise; + config: Record; + loadObject(reference: GitHubReference): Promise; log: PluginLogger; text: string; }): Promise { const lines = await Promise.all( - parseGitHubObjectLinks(args.text).map(async (link) => { + parseGitHubReferences(args.text, args.config).map(async (reference) => { try { - const object = await args.loadObject(link); - const title = - isRecord(object) && typeof object.title === "string" - ? object.title.replace(/\s+/g, " ").trim() - : ""; + const title = objectTitle( + reference.kind, + await args.loadObject(reference), + ); if (!title) return undefined; - return `${link.owner}/${link.name}#${link.number}: ${title.slice(0, TITLE_MAX_LENGTH)}`; + 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": link.kind, + "app.github.object_kind": reference.kind, "exception.message": error instanceof Error ? error.message : String(error), }); diff --git a/packages/junior-github/src/plugin.ts b/packages/junior-github/src/plugin.ts index ad1216fcac..e1821e169e 100644 --- a/packages/junior-github/src/plugin.ts +++ b/packages/junior-github/src/plugin.ts @@ -249,25 +249,37 @@ export function githubPlugin( hooks: { async conversationTitleContext(ctx) { return await loadGitHubConversationTitleContext({ + config: ctx.config, log: ctx.log, text: ctx.text, - loadObject: async (link) => { + loadObject: async (reference) => { + const { collection, permissions } = ( + { + commit: { + collection: "commits", + permissions: { contents: "read" }, + }, + issue: { + collection: "issues", + permissions: { issues: "read" }, + }, + pull_request: { + collection: "pulls", + permissions: { pull_requests: "read" }, + }, + } as const + )[reference.kind]; // Read one object with a token scoped to its repository. const token = await issueInstallationToken({ appIdEnv, privateKeyEnv, installationIdEnv, - permissions: - link.kind === "pull_request" - ? { pull_requests: "read" } - : { issues: "read" }, - repositories: [link.name], + permissions, + repositories: [reference.name], }); - const collection = - link.kind === "pull_request" ? "pulls" : "issues"; return await githubRequest( "https://api.github.com", - `/repos/${encodeURIComponent(link.owner)}/${encodeURIComponent(link.name)}/${collection}/${link.number}`, + `/repos/${encodeURIComponent(reference.owner)}/${encodeURIComponent(reference.name)}/${collection}/${encodeURIComponent(reference.id)}`, { signal: ctx.signal, token: token.token }, ); }, diff --git a/packages/junior-github/tests/conversation-title-context.test.ts b/packages/junior-github/tests/conversation-title-context.test.ts index dea40c03f9..65b6c36d2d 100644 --- a/packages/junior-github/tests/conversation-title-context.test.ts +++ b/packages/junior-github/tests/conversation-title-context.test.ts @@ -1,50 +1,107 @@ import { describe, expect, it, vi } from "vitest"; -import { loadGitHubConversationTitleContext } from "../src/conversation-title-context"; +import { + type GitHubReference, + loadGitHubConversationTitleContext, +} from "../src/conversation-title-context"; 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("reads each distinct issue and pull request link once", async () => { - const loadObject = vi.fn(async () => ({ title: "Title" })); + 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 }, + ]); + }); - await loadGitHubConversationTitleContext({ - loadObject, - log, - text: [ - "Fix this ", - "again https://github.com/getsentry/junior/issues/1583#issuecomment-1", - "see https://github.com/getsentry/sentry-docs/pull/42/files", - "and https://github.com/getsentry/junior/blob/main/README.md", - ].join("\n"), - }); + 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([]); + }); - expect(loadObject.mock.calls.map(([link]) => link)).toEqual([ - { kind: "issue", name: "junior", number: 1583, owner: "getsentry" }, - { - kind: "pull_request", - name: "sentry-docs", - number: 42, - owner: "getsentry", - }, + 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 (link: { number: number }) => { - if (link.number === 7) throw new Error("Not Found"); - return { title: " Conversation titles ignore issue titles " }; + 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 https://github.com/getsentry/junior/issues/1583 and https://github.com/getsentry/private/issues/7", + 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(loadObject).toHaveBeenCalledTimes(2); expect(log.warn).toHaveBeenCalledWith( "github.conversation_title_context.load_failed", expect.objectContaining({ "exception.message": "Not Found" }), diff --git a/packages/junior-plugin-api/README.md b/packages/junior-plugin-api/README.md index 7f5a6de74f..ac879a21b7 100644 --- a/packages/junior-plugin-api/README.md +++ b/packages/junior-plugin-api/README.md @@ -49,8 +49,9 @@ reports, and other typed hook surfaces exported by this package. core prompt. - `formatMarkdown` is a pure text rewrite into ordinary Markdown before delivery. - `conversationTitleContext` returns short facts about objects that the first - human message links, such as an issue number and title. Only the title model - sees them. Junior bounds the wait and ignores a failed hook. + human message refers to, such as an issue number and title. The hook gets the + plugin's config values, such as a default repository. 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 66f3ddd98f..31f98e0b72 100644 --- a/packages/junior-plugin-api/src/hooks.ts +++ b/packages/junior-plugin-api/src/hooks.ts @@ -51,6 +51,11 @@ export interface FormatMarkdownHookContext { /** 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; /** Ends the hook when the host stops waiting for title context. */ signal: AbortSignal; text: string; 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 index 406d34aee7..31f3c4df43 100644 --- a/packages/junior/src/chat/plugins/conversation-title-context.ts +++ b/packages/junior/src/chat/plugins/conversation-title-context.ts @@ -8,13 +8,30 @@ const TITLE_CONTEXT_TIMEOUT_MS = 5_000; const TITLE_CONTEXT_MAX_LINES = 5; const TITLE_CONTEXT_LINE_MAX_LENGTH = 200; +/** 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. Fails open: a failed plugin adds no facts. + * Conversation title. `configuration` holds config values by full key. + * Fails open: a failed plugin adds no facts. */ -export async function getPluginConversationTitleContext( - text: string, -): Promise { +export async function getPluginConversationTitleContext(args: { + configuration: Record; + text: string; +}): Promise { const signal = AbortSignal.timeout(TITLE_CONTEXT_TIMEOUT_MS); // Stop waiting at the deadline even when a hook ignores the signal. const timedOut = new Promise((_, reject) => { @@ -31,11 +48,12 @@ export async function getPluginConversationTitleContext( try { const lines = await Promise.race([ hook({ + config: pluginConfig(plugin.manifest.name, args.configuration), db: getDb(), log: createPluginLogger(plugin.manifest.name), plugin: { name: plugin.manifest.name }, signal, - text, + text: args.text, }), timedOut, ]); diff --git a/packages/junior/src/chat/services/conversation-title.ts b/packages/junior/src/chat/services/conversation-title.ts index b632c9fad3..e5bd5627fc 100644 --- a/packages/junior/src/chat/services/conversation-title.ts +++ b/packages/junior/src/chat/services/conversation-title.ts @@ -1,5 +1,7 @@ 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"; @@ -8,6 +10,7 @@ import { fallbackShortTitle, generateShortTitle, } from "@/chat/services/short-title"; +import type { Destination } from "@sentry/junior-plugin-api"; import type { ConversationMessage, ThreadConversationState, @@ -63,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; @@ -103,7 +129,12 @@ async function ensureConversationTitleOnce(args: { // Run input is task boilerplate; only human messages link objects. const context = kind === "conversation" - ? await getPluginConversationTitleContext(sourceText) + ? await getPluginConversationTitleContext({ + configuration: await resolveTitleConfiguration( + stored?.destination, + ), + text: sourceText, + }) : undefined; title = await generateShortTitle({ completeText, From 3867f1a428b7396e78fb94666f793eae23226d31 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:05:09 +0000 Subject: [PATCH 3/4] fix(conversations): Keep title requests unchanged without linked objects Add the linked object instruction only when plugins return title context. Other title requests then match their eval recordings again, so evals such as the image edit case replay instead of running live. Co-Authored-By: BYK --- packages/junior/src/chat/services/short-title.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/junior/src/chat/services/short-title.ts b/packages/junior/src/chat/services/short-title.ts index 77b66a50fc..e76d32e982 100644 --- a/packages/junior/src/chat/services/short-title.ts +++ b/packages/junior/src/chat/services/short-title.ts @@ -23,7 +23,6 @@ const TITLE_PROMPTS: Record = { conversation: [ "Generate a concise 5-8 word conversation title from the first user message below.", "Capture the user's main request.", - "When the message links an object listed under Linked objects, name it by its number and title, for example: Fix #123: Login fails on Safari.", "Reply with ONLY the title, with no quotes or trailing punctuation.", ], run: [ @@ -88,11 +87,15 @@ 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"), From b2e632f1d34af274566b27157192e35747f3ec53 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:23:43 +0000 Subject: [PATCH 4/4] fix(github): Read conversation title context through credentialed egress The title context hook minted its own installation token and called GitHub directly. That skipped Junior's credential proxy: egress policy, grant selection, lease reuse, and audit telemetry. Core now gives the hook host-owned egress as a conversation-title system actor. The GitHub hook reads issues, pull requests, and commits through it. The system actor gets only installation grants, never user tokens, and title work never starts authorization. Reads reuse the shared installation lease, so title context no longer mints a token per object. Co-Authored-By: BYK --- .../junior-github/src/credential-support.ts | 2 - packages/junior-github/src/plugin.ts | 43 +++++++------- .../tests/conversation-title-context.test.ts | 56 +++++++++++++++++++ packages/junior-plugin-api/README.md | 6 +- packages/junior-plugin-api/src/hooks.ts | 6 ++ .../plugins/conversation-title-context.ts | 17 ++++++ 6 files changed, 107 insertions(+), 23 deletions(-) diff --git a/packages/junior-github/src/credential-support.ts b/packages/junior-github/src/credential-support.ts index 6a3fe2676a..e3cd681a4f 100644 --- a/packages/junior-github/src/credential-support.ts +++ b/packages/junior-github/src/credential-support.ts @@ -40,7 +40,6 @@ export type GitHubGrant = PluginGrant & { interface GitHubRequestParams { body?: unknown; method?: string; - signal?: AbortSignal; token: string; } @@ -277,7 +276,6 @@ export async function githubRequest( ...(params.body ? { "Content-Type": "application/json" } : undefined), }, ...(params.body ? { body: JSON.stringify(params.body) } : undefined), - signal: params.signal, }); const text = await response.text(); diff --git a/packages/junior-github/src/plugin.ts b/packages/junior-github/src/plugin.ts index e1821e169e..78b2b10b0a 100644 --- a/packages/junior-github/src/plugin.ts +++ b/packages/junior-github/src/plugin.ts @@ -253,35 +253,40 @@ export function githubPlugin( log: ctx.log, text: ctx.text, loadObject: async (reference) => { - const { collection, permissions } = ( + const { collection, operation } = ( { commit: { collection: "commits", - permissions: { contents: "read" }, - }, - issue: { - collection: "issues", - permissions: { issues: "read" }, + operation: "github.commit.get", }, + issue: { collection: "issues", operation: "github.issue.get" }, pull_request: { collection: "pulls", - permissions: { pull_requests: "read" }, + operation: "github.pull.get", }, } as const )[reference.kind]; - // Read one object with a token scoped to its repository. - const token = await issueInstallationToken({ - appIdEnv, - privateKeyEnv, - installationIdEnv, - permissions, - repositories: [reference.name], + // 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, + }, + ), }); - return await githubRequest( - "https://api.github.com", - `/repos/${encodeURIComponent(reference.owner)}/${encodeURIComponent(reference.name)}/${collection}/${encodeURIComponent(reference.id)}`, - { signal: ctx.signal, token: token.token }, - ); + if (!response.ok) { + throw new Error( + `GitHub ${reference.kind} lookup failed with HTTP ${response.status}`, + ); + } + return await response.json(); }, }); }, diff --git a/packages/junior-github/tests/conversation-title-context.test.ts b/packages/junior-github/tests/conversation-title-context.test.ts index 65b6c36d2d..134191fe61 100644 --- a/packages/junior-github/tests/conversation-title-context.test.ts +++ b/packages/junior-github/tests/conversation-title-context.test.ts @@ -1,8 +1,10 @@ +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() }; @@ -107,4 +109,58 @@ describe("GitHub conversation title context", () => { 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 ac879a21b7..da89f47fbd 100644 --- a/packages/junior-plugin-api/README.md +++ b/packages/junior-plugin-api/README.md @@ -50,8 +50,10 @@ reports, and other typed hook surfaces exported by this package. - `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. Only the title model - sees the facts. Junior bounds the wait and ignores a failed hook. + 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 31f98e0b72..79581f04b9 100644 --- a/packages/junior-plugin-api/src/hooks.ts +++ b/packages/junior-plugin-api/src/hooks.ts @@ -31,6 +31,7 @@ import type { import type { AfterMcpToolHookContext, BeforeToolExecuteHookContext, + PluginEgress, PluginToolDefinition, SandboxPrepareHookContext, ToolRegistrationHookContext, @@ -56,6 +57,11 @@ export interface ConversationTitleContextHookContext extends PluginContext { * 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; diff --git a/packages/junior/src/chat/plugins/conversation-title-context.ts b/packages/junior/src/chat/plugins/conversation-title-context.ts index 31f3c4df43..c60d2c9a68 100644 --- a/packages/junior/src/chat/plugins/conversation-title-context.ts +++ b/packages/junior/src/chat/plugins/conversation-title-context.ts @@ -1,4 +1,6 @@ +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"; @@ -7,6 +9,13 @@ import { createPluginLogger } from "@/chat/plugins/logging"; 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( @@ -33,6 +42,13 @@ export async function getPluginConversationTitleContext(args: { 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), { @@ -50,6 +66,7 @@ export async function getPluginConversationTitleContext(args: { hook({ config: pluginConfig(plugin.manifest.name, args.configuration), db: getDb(), + egress, log: createPluginLogger(plugin.manifest.name), plugin: { name: plugin.manifest.name }, signal,