diff --git a/packages/api/src/info.ts b/packages/api/src/info.ts index 375666fc..baeb27da 100644 --- a/packages/api/src/info.ts +++ b/packages/api/src/info.ts @@ -35,4 +35,8 @@ export interface LiveStatusResponse { lastPollAt: string | null; /** Requests an agent has taken and not answered yet; it counts as listening while it works on them. */ working: number; + /** When diffity last fired the user's routine for this session, while that call is still expected to arrive. */ + calledAt: string | null; + /** Diffity has a routine to call for this user when no agent is listening. */ + routine: boolean; } diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index 854c7f32..775ba01f 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -11,13 +11,14 @@ import type { Config } from './config.js'; import { WEB_SESSION_MAX_AGE_SECONDS, type Users, type User, type WebSessions } from './users.js'; import { hashPresentedClientSecret, type OAuthProvider } from './oauth.js'; import type { AutomationTokens } from './automation.js'; -import { ServiceError, describeSession, type ReviewRequests, type ReviewService } from './service.js'; +import { ServiceError, describeSession, type ReviewRequestRow, type ReviewService } from './service.js'; import type { DevLoginProvider, IapLogin } from './login.js'; import { GitHubConnectError, type GitHubAppAccess } from './github-app.js'; import { createMcpServer } from './mcp.js'; import { MAX_WAIT_SECONDS, type Live } from './live.js'; +import type { Routine } from './routine.js'; import { uiApiRouter } from './ui-api.js'; -import { escapeHtml, page } from './html.js'; +import { SECTIONS_SCRIPT, escapeHtml, page } from './html.js'; import { randomToken } from './crypto.js'; import { SESSION_COOKIE, @@ -37,6 +38,7 @@ export interface AppDeps { automationTokens: AutomationTokens; service: ReviewService; live: Live; + routine: Routine; devLogin: DevLoginProvider | null; /** When set, identity comes from IAP's header on every request, and there is no login form. */ iap: IapLogin | null; @@ -91,7 +93,7 @@ function formTargetFor(redirectUri: string): string { } export function createApp(deps: AppDeps): express.Express { - const { config, users, webSessions, oauth, service, live } = deps; + const { config, users, webSessions, oauth, service, live, routine } = deps; const publicUrl = config.publicUrl; const mcpUrl = new URL('/mcp', publicUrl); const secureCookies = publicUrl.protocol === 'https:'; @@ -118,9 +120,9 @@ export function createApp(deps: AppDeps): express.Express { return userId ? users.get(userId) : null; }; - const sendPage = (res: Response, status: number, title: string, body: string, user: User | null, formTargets: string[] = []) => { + const sendPage = (res: Response, status: number, title: string, body: string, user: User | null, formTargets: string[] = [], selfScripts = false) => { res.status(status) - .set('Content-Security-Policy', pageContentSecurityPolicy(formTargets)) + .set('Content-Security-Policy', pageContentSecurityPolicy(formTargets, selfScripts)) .set('Cache-Control', 'no-store') .type('html') .send(page({ title, body, user })); @@ -145,6 +147,10 @@ export function createApp(deps: AppDeps): express.Express { res.json({ ok: true, version: deps.version }); }); + app.get('/sections.js', (_req, res) => { + res.type('text/javascript').set('Cache-Control', 'no-cache').send(SECTIONS_SCRIPT); + }); + if (deps.uiDir) { // Build artifacts, the same for everyone; nothing about a review is in them. app.use('/assets', express.static(join(deps.uiDir, 'assets'), { index: false, immutable: true, maxAge: '365d' })); @@ -209,17 +215,27 @@ export function createApp(deps: AppDeps): express.Express { verifier: { verifyAccessToken: async token => { const automation = await deps.automationTokens.verify(token); - if (!automation) { - return oauth.verifyAccessToken(token); + if (automation) { + return { + token, + clientId: `automation:${automation.name}`, + scopes: [], + // The SDK requires an expiry; an automation token lasts until it is revoked. + expiresAt: Number.MAX_SAFE_INTEGER, + extra: { userId: automation.userId, automation: true, automationName: automation.name }, + }; + } + const scoped = await live.verifySessionToken(token); + if (scoped) { + return { + token, + clientId: 'session', + scopes: [], + expiresAt: scoped.expiresAt, + extra: { userId: scoped.userId, sessionScope: scoped.sessionId }, + }; } - return { - token, - clientId: `automation:${automation.name}`, - scopes: [], - // The SDK requires an expiry; an automation token lasts until it is revoked. - expiresAt: Number.MAX_SAFE_INTEGER, - extra: { userId: automation.userId, automation: true, automationName: automation.name }, - }; + return oauth.verifyAccessToken(token); }, }, resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpUrl), @@ -232,14 +248,16 @@ export function createApp(deps: AppDeps): express.Express { return; } const automationName = req.auth!.extra!.automation === true ? String(req.auth!.extra!.automationName) : null; + const sessionScope = typeof req.auth!.extra!.sessionScope === 'string' ? req.auth!.extra!.sessionScope : undefined; const server = createMcpServer({ service, live, automationTokens: deps.automationTokens, userId, automation: automationName !== null, - agentName: automationName ?? (await oauth.clientsStore.clientName(req.auth!.clientId)) ?? 'Claude', + agentName: sessionScope ? 'Attendant' : automationName ?? (await oauth.clientsStore.clientName(req.auth!.clientId)) ?? 'Claude', version: deps.version, + sessionScope, }); // Stateless: every request is complete in itself, so nothing is held between them. const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true }); @@ -435,6 +453,7 @@ export function createApp(deps: AppDeps): express.Express { return; } const settings = await users.settings(user.id); + const routineSettings = await users.routineSettings(user.id); sendPage(res, 200, 'Settings', `

Settings

GitHub access

@@ -448,6 +467,18 @@ export function createApp(deps: AppDeps): express.Express {

+

Call an agent

+

When you ask for a review from this page, or ask a question while no agent is listening, diffity fires this Claude + routine with the session and a token that works on that session only.

+
+

+

+ ${routineSettings.keySet ? 'Key is set' : 'No key yet'}

+ +
+ ${routineSettings.url || routineSettings.keySet + ? '
' + : ''}

Connect an agent

Add this server to Claude Code as an MCP connector; it signs you in through this page the first time. Its review prompt holds the review method.

@@ -483,6 +514,27 @@ export function createApp(deps: AppDeps): express.Express { res.redirect(303, '/settings'); }); + app.post('/settings/routine', sameOrigin, forms, async (req, res) => { + const user = await userFor(req); + if (!user) { + notSignedIn(res, '/settings'); + return; + } + if (req.body?.clear === '1') { + await users.setRoutine(user.id, null, null); + res.redirect(303, '/settings'); + return; + } + const url = typeof req.body?.url === 'string' ? req.body.url.trim() : ''; + if (!url.startsWith('https://')) { + sendPage(res, 400, 'Settings', '

The fire URL must start with https://.

Settings

', user); + return; + } + const key = typeof req.body?.key === 'string' ? req.body.key.trim() : ''; + await users.setRoutine(user.id, url, key || undefined); + res.redirect(303, '/settings'); + }); + app.get('/github/connect', async (req, res) => { const user = await userFor(req); if (!user) { @@ -528,47 +580,54 @@ export function createApp(deps: AppDeps): express.Express { res.redirect(303, '/settings'); }); - const reviewRequestsSection = async (user: User, listening: boolean): Promise => { - let found: ReviewRequests | null; + const section = (heading: string, content: string): string => + `

${heading}

${content}
`; + + const pullsSection = async ( + heading: string, + connectFor: string, + empty: string, + list: () => Promise<{ rows: ReviewRequestRow[]; hidden: number } | null>, + reachable: boolean, + ): Promise => { + let found: { rows: ReviewRequestRow[]; hidden: number } | null; try { - found = await service.reviewRequests(user.id); + found = await list(); } catch (err) { - return `

Awaiting your review

-

GitHub could not be asked: ${escapeHtml(err instanceof Error ? err.message : String(err))}

`; + return section(heading, `

GitHub could not be asked: ${escapeHtml(err instanceof Error ? err.message : String(err))}

`); } if (!found) { - return `

Awaiting your review

-

Connect GitHub on Settings to see pull requests waiting for your review.

`; + return section(heading, `

Connect GitHub on Settings to see ${connectFor}.

`); } const hidden = found.hidden > 0 ? ` (${found.hidden} hidden by your filters)` : ''; - if (found.requests.length === 0) { - return `

Awaiting your review

Nothing is waiting for your review.${hidden}

`; + if (found.rows.length === 0) { + return section(heading, `

${empty}${hidden}

`); } - const rows = found.requests.map(request => { + const rows = found.rows.map(request => { const slug = `${request.owner}/${request.repo}`; const action = request.session - ? `open · ${escapeHtml(request.session.headSha.slice(0, 7))}${listening + ? `open · ${escapeHtml(request.session.headSha.slice(0, 7))}${reachable ? `
` : ''}` - : `
+ : ` - ${listening + ${reachable ? '' : ''}
`; return ` ${escapeHtml(`${slug}#${request.number}`)} - ${escapeHtml(request.title)} + ${escapeHtml(request.title)}${request.draft ? ' draft' : ''} ${escapeHtml(request.author)} ${escapeHtml(request.updatedAt.slice(0, 16).replace('T', ' '))} ${action} `; }).join(''); - return `

Awaiting your review

+ return section(heading, ` ${rows}
Pull requestTitleAuthorUpdated
- ${hidden ? `

${hidden}

` : ''}`; + ${hidden ? `

${hidden}

` : ''}`); }; app.get('/', async (req, res) => { @@ -579,10 +638,21 @@ export function createApp(deps: AppDeps): express.Express { } const status = await live.userStatus(user.id); const listening = status.listening; - const [requests, sessions] = await Promise.all([reviewRequestsSection(user, listening), reviews.listSessions(user.id)]); + const reachable = listening || (await routine.configured(user.id)); + const [requests, own, sessions] = await Promise.all([ + pullsSection('Awaiting your review', 'pull requests waiting for your review', 'Nothing is waiting for your review.', async () => { + const found = await service.reviewRequests(user.id); + return found && { rows: found.requests, hidden: found.hidden }; + }, reachable), + pullsSection('Your open pull requests', 'your open pull requests', 'You have no open pull requests.', async () => { + const rows = await service.ownPulls(user.id); + return rows && { rows, hidden: 0 }; + }, reachable), + reviews.listSessions(user.id), + ]); const rows = sessions.map(session => ` - ${escapeHtml(`${session.owner}/${session.repo}`)} + ${escapeHtml(`${session.owner}/${session.repo}`)} ${escapeHtml(describeSession(session))} ${escapeHtml(session.createdAt.slice(0, 16).replace('T', ' '))} `).join(''); @@ -591,10 +661,10 @@ export function createApp(deps: AppDeps): express.Express { : listening ? '

Agent listening · Create and review hands a pull request to it

' : ''; - sendPage(res, 200, 'Sessions', agent + requests + (sessions.length > 0 - ? `

Your review sessions

${rows}
RepositoryChangeCreated
` - : `

No review sessions yet

Ask your agent to create_session through the diffity MCP connector. - See settings for how to add it.

`), user); + sendPage(res, 200, 'Sessions', agent + requests + own + (sessions.length > 0 + ? section('Your review sessions', `${rows}
RepositoryChangeCreated
`) + : section('No review sessions yet', `

Ask your agent to create_session through the diffity MCP connector. + See settings for how to add it.

`)) + '', user, [], true); }); app.post('/sessions', sameOrigin, forms, async (req, res) => { @@ -608,6 +678,7 @@ export function createApp(deps: AppDeps): express.Express { const { session } = await service.createSession(user.id, { repo, pr: Number(req.body?.pr) }); if (req.body?.review === '1') { await live.queueReview(user.id, session.id); + await routine.fire(user.id, session, 'review'); } res.redirect(303, `/s/${session.id}/`); } catch (err) { @@ -631,6 +702,7 @@ export function createApp(deps: AppDeps): express.Express { return; } await live.queueReview(user.id, session.id); + await routine.fire(user.id, session, 'review'); res.redirect(303, '/'); }); @@ -641,14 +713,14 @@ export function createApp(deps: AppDeps): express.Express { res.status(401).json({ error: 'Sign in first' }); return; } - res.json((await live.userStatus(user.id)) satisfies LiveStatusResponse); + res.json({ ...(await live.userStatus(user.id)), routine: await routine.configured(user.id) } satisfies LiveStatusResponse); }); app.use('/s/:sid/api', requireSameOrigin(publicUrl), (_req, res, next) => { res.set('Content-Security-Policy', UI_CONTENT_SECURITY_POLICY); res.set('Cache-Control', 'no-store'); next(); - }, uiApiRouter({ service, live, userFor })); + }, uiApiRouter({ service, live, routine, userFor })); app.get('/s/:sid{/*rest}', async (req, res) => { const user = await userFor(req); diff --git a/packages/server/src/db.ts b/packages/server/src/db.ts index 80d2a262..4808d678 100644 --- a/packages/server/src/db.ts +++ b/packages/server/src/db.ts @@ -232,6 +232,17 @@ export const MIGRATIONS: Migration[] = [ ); `, }, + { + version: 6, + sql: ` + ALTER TABLE live_tokens ADD COLUMN scope TEXT NOT NULL DEFAULT 'wait'; + ALTER TABLE live_tokens ADD CONSTRAINT live_tokens_scope_check + CHECK (scope = 'wait' OR (scope = 'session' AND session_id IS NOT NULL)); + ALTER TABLE users ADD COLUMN routine_url TEXT; + ALTER TABLE users ADD COLUMN routine_key TEXT; + ALTER TABLE sessions ADD COLUMN routine_fired_at BIGINT; + `, + }, ]; /** Any constant; two instances starting together take turns migrating. */ diff --git a/packages/server/src/github.ts b/packages/server/src/github.ts index cb9e5660..55c4564e 100644 --- a/packages/server/src/github.ts +++ b/packages/server/src/github.ts @@ -97,6 +97,7 @@ const PAGE_SIZE = 100; const MAX_PAGES = 30; const REVIEW_REQUESTS_QUERY = 'is:pr is:open archived:false review-requested:@me'; +const OWN_PULLS_QUERY = 'is:pr is:open archived:false author:@me'; const REVIEW_THREADS_QUERY = `query($owner:String!,$repo:String!,$number:Int!){ repository(owner:$owner,name:$repo){ @@ -290,15 +291,24 @@ export class GitHubApi { } /** Open pull requests waiting for the token's own review, most recently updated first. */ - async reviewRequests(token: string): Promise { - const query = new URLSearchParams({ q: REVIEW_REQUESTS_QUERY, sort: 'updated', order: 'desc', per_page: '50' }); + reviewRequests(token: string): Promise { + return this.searchPulls(token, REVIEW_REQUESTS_QUERY); + } + + /** The token's own open pull requests, most recently updated first. */ + ownPulls(token: string): Promise { + return this.searchPulls(token, OWN_PULLS_QUERY); + } + + private async searchPulls(token: string, q: string): Promise { + const query = new URLSearchParams({ q, sort: 'updated', order: 'desc', per_page: '50' }); let result: { items?: RawSearchIssue[] }; try { result = await this.json(token, `/search/issues?${query.toString()}`); } catch (err) { // A search GitHub refuses (rate limit, an App without access) leaves the list empty, not the page broken. if (err instanceof GitHubApiError && (err.status === 422 || err.status === 403)) { - console.warn(`Listing review requests failed: ${err.message}`); + console.warn(`Searching pull requests failed: ${err.message}`); return []; } throw err; diff --git a/packages/server/src/html.ts b/packages/server/src/html.ts index 702ed9fa..44df093a 100644 --- a/packages/server/src/html.ts +++ b/packages/server/src/html.ts @@ -23,10 +23,24 @@ const STYLE = ` textarea { font: inherit; padding: 6px 8px; max-width: 100%; } button { font: inherit; padding: 6px 12px; cursor: pointer; } form.inline { display: inline; } + summary { cursor: pointer; } + summary > h2 { display: inline-block; } code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; } pre { padding: 8px; border: 1px solid var(--line); overflow-x: auto; } `; +/** Keeps each collapsible section of a page open or closed as the user last left it. */ +export const SECTIONS_SCRIPT = `for (const details of document.querySelectorAll('details')) { + const key = 'diffity.section:' + details.querySelector('summary').textContent.trim(); + try { + if (localStorage.getItem(key) === 'closed') details.open = false; + } catch {} + details.addEventListener('toggle', () => { + try { localStorage.setItem(key, details.open ? 'open' : 'closed'); } catch {} + }); +} +`; + export function page(input: { title: string; body: string; user?: { email: string } | null }): string { const nav = input.user ? `SessionsSettings diff --git a/packages/server/src/live.ts b/packages/server/src/live.ts index f23a7e0c..3bc6fae0 100644 --- a/packages/server/src/live.ts +++ b/packages/server/src/live.ts @@ -1,8 +1,10 @@ import { randomUUID } from 'node:crypto'; import type { Db } from './db.js'; import { randomToken, sha256 } from './crypto.js'; +import { ROUTINE_COOLDOWN_MS } from './routine.js'; export const LIVE_TOKEN_TTL_MS = 12 * 60 * 60 * 1000; +export const SESSION_TOKEN_TTL_MS = 48 * 60 * 60 * 1000; /** A request whose agent went quiet for this long is handed out again. */ export const CLAIM_TTL_MS = 10 * 60 * 1000; /** Under the load balancer's 30 s backend timeout. */ @@ -79,15 +81,17 @@ export interface LiveStatus { listening: boolean; lastPollAt: string | null; working: number; + calledAt: string | null; } /** An agent that holds a request has stopped polling to work on it, and still counts as listening. */ -function liveStatus(polls: (number | null | undefined)[], working: number, now: number): LiveStatus { +function liveStatus(polls: (number | null | undefined)[], working: number, now: number, calledAt: string | null = null): LiveStatus { const latest = Math.max(...polls.map(poll => poll ?? -Infinity)); return { listening: now - latest < LISTENING_WINDOW_MS || working > 0, lastPollAt: Number.isFinite(latest) ? new Date(latest).toISOString() : null, working, + calledAt, }; } @@ -108,17 +112,34 @@ export class Live { * user's. Only its hash is kept. */ async issueToken(userId: string, sessionId: string | null, now = Date.now()): Promise<{ token: string; expiresAt: number }> { + return this.insertToken(userId, sessionId, 'wait', now + LIVE_TOKEN_TTL_MS); + } + + /** A token for a routine diffity called: the review tools on this one session, and waiting on it. */ + async issueSessionToken(userId: string, sessionId: string, now = Date.now()): Promise<{ token: string; expiresAt: number }> { + return this.insertToken(userId, sessionId, 'session', now + SESSION_TOKEN_TTL_MS); + } + + private async insertToken(userId: string, sessionId: string | null, scope: 'wait' | 'session', expiresAt: number) { const token = randomToken(); - const expiresAt = now + LIVE_TOKEN_TTL_MS; - await this.db.query('INSERT INTO live_tokens (token_hash, user_id, session_id, expires_at) VALUES ($1, $2, $3, $4)', [ + await this.db.query('INSERT INTO live_tokens (token_hash, user_id, session_id, scope, expires_at) VALUES ($1, $2, $3, $4, $5)', [ sha256(token), userId, sessionId, + scope, expiresAt, ]); return { token, expiresAt }; } + async verifySessionToken(token: string, now = Date.now()): Promise<{ userId: string; sessionId: string; expiresAt: number } | null> { + const row = await this.db.one<{ user_id: string; session_id: string; expires_at: number }>( + "SELECT user_id, session_id, expires_at FROM live_tokens WHERE token_hash = $1 AND scope = 'session'", + [sha256(token)], + ); + return row && row.expires_at > now ? { userId: row.user_id, sessionId: row.session_id, expiresAt: row.expires_at } : null; + } + /** * Who may wait where with this token. Without a session only a user token qualifies; with one, * that session's token, or a user token when the session is the user's. @@ -268,12 +289,14 @@ export class Live { /** An agent listens on a session when it waits on that session or on everything of the user's. */ async status(userId: string, sessionId: string, now = Date.now()): Promise { - const row = await this.db.one<{ session_poll: number | null; user_poll: number | null }>( - `SELECT s.live_polled_at AS session_poll, u.live_polled_at AS user_poll + const row = await this.db.one<{ session_poll: number | null; user_poll: number | null; fired: number | null }>( + `SELECT s.live_polled_at AS session_poll, u.live_polled_at AS user_poll, s.routine_fired_at AS fired FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.id = $1 AND s.user_id = $2`, [sessionId, userId], ); - return liveStatus([row?.session_poll, row?.user_poll], row ? await this.working(userId, sessionId, now) : 0, now); + const fired = row?.fired ?? null; + const calledAt = fired !== null && now - fired < ROUTINE_COOLDOWN_MS ? new Date(fired).toISOString() : null; + return liveStatus([row?.session_poll, row?.user_poll], row ? await this.working(userId, sessionId, now) : 0, now, calledAt); } /** Whether an agent waits on the user's whole queue, which is what a new session's review needs. */ diff --git a/packages/server/src/mcp.ts b/packages/server/src/mcp.ts index ceab062b..294312b3 100644 --- a/packages/server/src/mcp.ts +++ b/packages/server/src/mcp.ts @@ -19,6 +19,8 @@ export interface McpContext { /** Who the comments are from, as the connecting client named itself when it registered. */ agentName: string; version: string; + /** The one session a session token works on. */ + sessionScope?: string; } function json(value: unknown): CallToolResult { @@ -94,9 +96,15 @@ export function createMcpServer(ctx: McpContext): McpServer { const agent: CommentAuthor = { name: ctx.agentName, type: 'agent' }; const server = new McpServer({ name: 'diffity', version: ctx.version }); - const session = (id: string): Promise => service.requireSession(userId, id); + const session = async (id: string): Promise => { + const record = await service.requireSession(userId, id); + if (ctx.sessionScope !== undefined && record.id !== ctx.sessionScope) { + throw new ServiceError(`This token is for session ${ctx.sessionScope.slice(0, 8)} only`); + } + return record; + }; - const thread = async (id: string, sessionId?: string): Promise => { + const thread = async (id: string, sessionId = ctx.sessionScope): Promise => { const found = await reviews.getThread(userId, id); if (!found) { throw new ServiceError(`No thread matches ${id}`); @@ -112,6 +120,9 @@ export function createMcpServer(ctx: McpContext): McpServer { if (!found) { throw new ServiceError(`No walkthrough matches ${id}`); } + if (ctx.sessionScope !== undefined) { + await session(found.sessionId); + } return found; }; @@ -128,112 +139,170 @@ export function createMcpServer(ctx: McpContext): McpServer { createdAt: record.createdAt, }); - server.registerTool( - 'create_session', - { - description: - 'Open a review session on pushed code. Give the repository and exactly one of: `pr` (a pull request number); ' - + '`base` and `head` (full commit shas); or `base` and `patch` (a unified diff applied to base, for work that is not pushed). ' - + 'Returns the session id, the URL the user opens to read the review, and the changed files. ' - + 'With `review: true` the session is queued for the agent waiting on the user\'s queue (live_token {}) to review. ' - + 'The `review` prompt of this server holds the method for reviewing through these tools.', - inputSchema: { - repo: z.string().describe('"owner/name" on GitHub'), - pr: z.number().int().positive().optional(), - base: z.string().optional().describe('Full 40-character commit sha'), - head: z.string().optional().describe('Full 40-character commit sha'), - patch: z.string().optional().describe('Unified diff (git diff output) against base'), - review: z.boolean().optional().describe('Queue the session for the agent waiting on your queue to review'), + // A session token's run works on its one session and reads the code from its own checkout. + if (!ctx.sessionScope) { + server.registerTool( + 'create_session', + { + description: + 'Open a review session on pushed code. Give the repository and exactly one of: `pr` (a pull request number); ' + + '`base` and `head` (full commit shas); or `base` and `patch` (a unified diff applied to base, for work that is not pushed). ' + + 'Returns the session id, the URL the user opens to read the review, and the changed files. ' + + 'With `review: true` the session is queued for the agent waiting on the user\'s queue (live_token {}) to review. ' + + 'The `review` prompt of this server holds the method for reviewing through these tools.', + inputSchema: { + repo: z.string().describe('"owner/name" on GitHub'), + pr: z.number().int().positive().optional(), + base: z.string().optional().describe('Full 40-character commit sha'), + head: z.string().optional().describe('Full 40-character commit sha'), + patch: z.string().optional().describe('Unified diff (git diff output) against base'), + review: z.boolean().optional().describe('Queue the session for the agent waiting on your queue to review'), + }, }, - }, - guarded(async ({ review, ...input }) => { - const created = await service.createSession(userId, input); - const queued = review ? await ctx.live.queueReview(userId, created.session.id) : false; - return json({ - ...describe(created.session), - ...(review ? { review: queued ? 'queued' : 'already queued' } : {}), - created: created.created, - carriedThreads: created.carried, - files: created.files.map(file => ({ - status: file.status, - path: file.newPath, - ...(file.oldPath !== file.newPath ? { oldPath: file.oldPath } : {}), - })), - }); - }), - ); - - server.registerTool( - 'list_sessions', - { - description: 'List your review sessions, newest first.', - inputSchema: { repo: z.string().optional().describe('Only sessions of this "owner/name"') }, - annotations: { readOnlyHint: true }, - }, - guarded(async ({ repo }) => { - const [owner, name] = repo?.split('/') ?? []; - return json((await reviews.listSessions(userId, { owner, repo: name })).map(describe)); - }), - ); + guarded(async ({ review, ...input }) => { + const created = await service.createSession(userId, input); + const queued = review ? await ctx.live.queueReview(userId, created.session.id) : false; + return json({ + ...describe(created.session), + ...(review ? { review: queued ? 'queued' : 'already queued' } : {}), + created: created.created, + carriedThreads: created.carried, + files: created.files.map(file => ({ + status: file.status, + path: file.newPath, + ...(file.oldPath !== file.newPath ? { oldPath: file.oldPath } : {}), + })), + }); + }), + ); + + server.registerTool( + 'list_sessions', + { + description: 'List your review sessions, newest first.', + inputSchema: { repo: z.string().optional().describe('Only sessions of this "owner/name"') }, + annotations: { readOnlyHint: true }, + }, + guarded(async ({ repo }) => { + const [owner, name] = repo?.split('/') ?? []; + return json((await reviews.listSessions(userId, { owner, repo: name })).map(describe)); + }), + ); + + server.registerTool( + 'list_review_requests', + { + description: + 'List the open pull requests waiting for your review on GitHub, after your filters on the Settings page, ' + + 'each with your newest session on it, if any. Review one with create_session { repo, pr }.', + inputSchema: {}, + annotations: { readOnlyHint: true }, + }, + guarded(async () => { + const found = await service.reviewRequests(userId); + if (!found) { + throw new ServiceError(`Connect GitHub first, at ${service.settingsUrl()}`); + } + return json({ + requests: found.requests.map(request => ({ + repo: `${request.owner}/${request.repo}`, + pr: request.number, + title: request.title, + author: request.author, + draft: request.draft, + updatedAt: request.updatedAt, + url: request.url, + session: request.session ? { ...request.session, url: service.sessionUrl(request.session.id) } : null, + })), + hidden: found.hidden, + }); + }), + ); + + server.registerTool( + 'get_diff', + { + description: "The session's unified diff; line numbers are in the @@ hunk headers.", + inputSchema: { session: sessionArg, file: z.string().optional().describe('Only this file') }, + annotations: { readOnlyHint: true }, + }, + guarded(async args => { + const raw = await service.diffText(await session(args.session), { path: args.file }); + return text(raw.trim() ? raw : 'No changes.'); + }), + ); + + server.registerTool( + 'get_file', + { + description: 'A file as it is on one side of the diff: `new` is the head, `old` the base.', + inputSchema: { session: sessionArg, path: z.string().min(1), side: z.enum(['old', 'new']).default('new') }, + annotations: { readOnlyHint: true }, + }, + guarded(async args => { + const content = await service.readFile(await session(args.session), args.side, args.path); + if (content === null) { + throw new ServiceError(`${args.path} does not exist on the ${args.side} side`); + } + return text(content); + }), + ); - server.registerTool( - 'list_review_requests', - { - description: - 'List the open pull requests waiting for your review on GitHub, after your filters on the Settings page, ' - + 'each with your newest session on it, if any. Review one with create_session { repo, pr }.', - inputSchema: {}, - annotations: { readOnlyHint: true }, - }, - guarded(async () => { - const found = await service.reviewRequests(userId); - if (!found) { - throw new ServiceError(`Connect GitHub first, at ${service.settingsUrl()}`); + const requireSignedIn = () => { + if (ctx.automation) { + throw new ServiceError('Automation tokens are managed by a signed-in agent, not by an automation token'); } - return json({ - requests: found.requests.map(request => ({ - repo: `${request.owner}/${request.repo}`, - pr: request.number, - title: request.title, - author: request.author, - draft: request.draft, - updatedAt: request.updatedAt, - url: request.url, - session: request.session ? { ...request.session, url: service.sessionUrl(request.session.id) } : null, - })), - hidden: found.hidden, - }); - }), - ); + }; - server.registerTool( - 'get_diff', - { - description: "The session's unified diff; line numbers are in the @@ hunk headers.", - inputSchema: { session: sessionArg, file: z.string().optional().describe('Only this file') }, - annotations: { readOnlyHint: true }, - }, - guarded(async args => { - const raw = await service.diffText(await session(args.session), { path: args.file }); - return text(raw.trim() ? raw : 'No changes.'); - }), - ); + const tokenName = z.string().trim().min(1).max(100).describe('What the token is for, e.g. "pr-review routine"'); - server.registerTool( - 'get_file', - { - description: 'A file as it is on one side of the diff: `new` is the head, `old` the base.', - inputSchema: { session: sessionArg, path: z.string().min(1), side: z.enum(['old', 'new']).default('new') }, - annotations: { readOnlyHint: true }, - }, - guarded(async args => { - const content = await service.readFile(await session(args.session), args.side, args.path); - if (content === null) { - throw new ServiceError(`${args.path} does not exist on the ${args.side} side`); - } - return text(content); - }), - ); + server.registerTool( + 'automation_token', + { + description: + 'Mint a bearer token for an agent that cannot sign in, such as a scheduled routine: it reaches this MCP endpoint ' + + 'as you, with every tool, until revoked. Shown once; store it as a secret.', + inputSchema: { name: tokenName }, + }, + guarded(async args => { + requireSignedIn(); + const token = await ctx.automationTokens.mint(userId, args.name); + return json({ + name: args.name, + token, + use: `Send it as "Authorization: Bearer " to ${new URL('/mcp', service.publicUrl).href}. It is not shown again.`, + }); + }), + ); + + server.registerTool( + 'automation_tokens', + { + description: 'List your automation tokens, without the tokens themselves.', + inputSchema: {}, + annotations: { readOnlyHint: true }, + }, + guarded(async () => { + requireSignedIn(); + return json(await ctx.automationTokens.list(userId)); + }), + ); + + server.registerTool( + 'automation_token_revoke', + { + description: 'Revoke one of your automation tokens by name.', + inputSchema: { name: tokenName }, + }, + guarded(async args => { + requireSignedIn(); + if (!(await ctx.automationTokens.revoke(userId, args.name))) { + throw new ServiceError(`You have no automation token named "${args.name}"`); + } + return text(`Revoked automation token "${args.name}"`); + }), + ); + } server.registerTool( 'get_standards', @@ -371,7 +440,8 @@ export function createMcpServer(ctx: McpContext): McpServer { inputSchema: { id: z.string().min(1), body, session: sessionArg.optional() }, }, guarded(async args => { - const scoped = args.session === undefined ? undefined : (await session(args.session)).id; + const asked = args.session ?? ctx.sessionScope; + const scoped = asked === undefined ? undefined : (await session(asked)).id; const comment = await reviews.findComment(userId, args.id); let commentId: string; let sessionId: string; @@ -516,7 +586,8 @@ export function createMcpServer(ctx: McpContext): McpServer { inputSchema: { session: sessionArg.optional() }, }, guarded(async args => { - const record = args.session === undefined ? null : await session(args.session); + const scoped = ctx.sessionScope ?? args.session; + const record = scoped === undefined ? null : await session(scoped); const { token, expiresAt } = await ctx.live.issueToken(userId, record?.id ?? null); const query = record ? `session=${record.id}&wait=${MAX_WAIT_SECONDS}` : `wait=${MAX_WAIT_SECONDS}`; const pollUrl = new URL(`/live/await?${query}`, service.publicUrl).href; @@ -560,61 +631,6 @@ export function createMcpServer(ctx: McpContext): McpServer { }), ); - const requireSignedIn = () => { - if (ctx.automation) { - throw new ServiceError('Automation tokens are managed by a signed-in agent, not by an automation token'); - } - }; - - const tokenName = z.string().trim().min(1).max(100).describe('What the token is for, e.g. "pr-review routine"'); - - server.registerTool( - 'automation_token', - { - description: - 'Mint a bearer token for an agent that cannot sign in, such as a scheduled routine: it reaches this MCP endpoint ' - + 'as you, with every tool, until revoked. Shown once; store it as a secret.', - inputSchema: { name: tokenName }, - }, - guarded(async args => { - requireSignedIn(); - const token = await ctx.automationTokens.mint(userId, args.name); - return json({ - name: args.name, - token, - use: `Send it as "Authorization: Bearer " to ${new URL('/mcp', service.publicUrl).href}. It is not shown again.`, - }); - }), - ); - - server.registerTool( - 'automation_tokens', - { - description: 'List your automation tokens, without the tokens themselves.', - inputSchema: {}, - annotations: { readOnlyHint: true }, - }, - guarded(async () => { - requireSignedIn(); - return json(await ctx.automationTokens.list(userId)); - }), - ); - - server.registerTool( - 'automation_token_revoke', - { - description: 'Revoke one of your automation tokens by name.', - inputSchema: { name: tokenName }, - }, - guarded(async args => { - requireSignedIn(); - if (!(await ctx.automationTokens.revoke(userId, args.name))) { - throw new ServiceError(`You have no automation token named "${args.name}"`); - } - return text(`Revoked automation token "${args.name}"`); - }), - ); - server.registerPrompt( 'review', { diff --git a/packages/server/src/routine.ts b/packages/server/src/routine.ts new file mode 100644 index 00000000..d2d230b7 --- /dev/null +++ b/packages/server/src/routine.ts @@ -0,0 +1,85 @@ +import type { Db } from './db.js'; +import type { Live } from './live.js'; +import type { ReviewSessionRecord } from './reviews.js'; +import type { Users } from './users.js'; + +export const ROUTINE_COOLDOWN_MS = 10 * 60 * 1000; +const FIRE_TIMEOUT_MS = 10_000; + +export type RoutineFireKind = 'review' | 'ask'; +export type RoutineFireResult = 'fired' | 'cooldown' | 'unconfigured' | 'failed'; + +/** Calls the user's Claude routine (an API trigger) to attend a session; the run itself happens elsewhere. */ +export class Routine { + constructor( + private readonly db: Db, + private readonly users: Users, + private readonly live: Live, + private readonly publicUrl: URL, + private readonly fetchImpl: typeof fetch = fetch, + ) {} + + async configured(userId: string): Promise { + const settings = await this.users.routineSettings(userId); + return !!settings.url && settings.keySet; + } + + /** Fires the user's routine for the session unless none is configured or one fired within the cooldown; never throws. */ + async fire(userId: string, session: ReviewSessionRecord, kind: RoutineFireKind, now = Date.now()): Promise { + try { + const routine = await this.users.routine(userId); + if (!routine) { + return 'unconfigured'; + } + const claimed = await this.db.one( + `UPDATE sessions SET routine_fired_at = $3 WHERE id = $1 AND user_id = $2 + AND (routine_fired_at IS NULL OR routine_fired_at <= $4) RETURNING id`, + [session.id, userId, now, now - ROUTINE_COOLDOWN_MS], + ); + if (!claimed) { + return 'cooldown'; + } + const failure = await this.post(routine, userId, session, kind); + if (failure === null) { + return 'fired'; + } + console.error(`routine fire failed for session ${session.id}: ${failure}`); + await this.db.query('UPDATE sessions SET routine_fired_at = NULL WHERE id = $1 AND routine_fired_at = $2', [session.id, now]); + return 'failed'; + } catch (err) { + console.error(`routine fire failed for session ${session.id}: ${err instanceof Error ? err.message : String(err)}`); + return 'failed'; + } + } + + /** Null when the routine took the call, else why not. */ + private async post(routine: { url: string; key: string }, userId: string, session: ReviewSessionRecord, kind: RoutineFireKind): Promise { + const { token } = await this.live.issueSessionToken(userId, session.id); + const text = [ + 'diffity session', session.id, + 'repo', `${session.owner}/${session.repo}`, + 'pr', session.prNumber ?? '-', + 'base', session.baseSha, + 'head', session.headSha, + 'kind', kind, + 'url', new URL(`/s/${session.id}/`, this.publicUrl).href, + 'token', token, + ].join(' '); + try { + const res = await this.fetchImpl(routine.url, { + method: 'POST', + headers: { + Authorization: `Bearer ${routine.key}`, + 'anthropic-beta': 'experimental-cc-routine-2026-04-01', + 'anthropic-version': '2023-06-01', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ text }), + signal: AbortSignal.timeout(FIRE_TIMEOUT_MS), + }); + return res.ok ? null : String(res.status); + } catch (err) { + return err instanceof Error ? err.message : String(err); + } + } +} diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index 2b714d40..e18d4185 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -12,6 +12,7 @@ import { AutomationTokens } from './automation.js'; import { ReviewService } from './service.js'; import { DevLoginProvider, IapLogin } from './login.js'; import { Live } from './live.js'; +import { Routine } from './routine.js'; import { createApp } from './app.js'; export interface ServerOverrides { @@ -23,6 +24,8 @@ export interface ServerOverrides { uiDir?: string | null; rateLimit?: boolean; version?: string; + /** What fires the users' routines, instead of the network. */ + routineFetch?: typeof fetch; } export interface RunningServer { @@ -63,6 +66,7 @@ export async function startServer( const access = overrides.gitHubAccess ?? githubApp ?? new StoredTokenAccess(users, config.devGitHubToken); const service = new ReviewService(reviews, mirrors, api, access, config.publicUrl, users, bot); const live = new Live(db); + const routine = new Routine(db, users, live, config.publicUrl, overrides.routineFetch); const app = createApp({ config, @@ -72,6 +76,7 @@ export async function startServer( automationTokens: new AutomationTokens(db), service, live, + routine, devLogin: config.devLogin ? new DevLoginProvider(config) : null, iap: config.iapAudience ? new IapLogin({ ...config, iapAudience: config.iapAudience }, overrides.iapKeys) : null, githubApp, diff --git a/packages/server/src/service.ts b/packages/server/src/service.ts index a6a996ef..7d554536 100644 --- a/packages/server/src/service.ts +++ b/packages/server/src/service.ts @@ -168,6 +168,26 @@ export class ReviewService { /** Null when the user has no GitHub access to ask with. */ async reviewRequests(userId: string, now = Date.now()): Promise { + const all = await this.searchPulls(userId, 'review', token => this.github.reviewRequests(token), now); + if (!all) { + return null; + } + const shown = filterReviewRequests(all, await this.users.settings(userId)); + return { requests: await this.withSessions(userId, shown), hidden: all.length - shown.length }; + } + + /** Null when the user has no GitHub access to ask with. */ + async ownPulls(userId: string, now = Date.now()): Promise { + const all = await this.searchPulls(userId, 'own', token => this.github.ownPulls(token), now); + return all && this.withSessions(userId, all); + } + + private async searchPulls( + userId: string, + kind: 'review' | 'own', + search: (token: string) => Promise, + now: number, + ): Promise { const token = await this.access.tokenFor(userId); if (!token) { return null; @@ -177,24 +197,24 @@ export class ReviewService { this.reviewRequestCache.delete(key); } } - let cached = this.reviewRequestCache.get(userId); + const key = `${kind}:${userId}`; + let cached = this.reviewRequestCache.get(key); if (!cached || cached.token !== token) { - const entry = { token, at: now, requests: this.github.reviewRequests(token) }; - this.reviewRequestCache.set(userId, entry); + const entry = { token, at: now, requests: search(token) }; + this.reviewRequestCache.set(key, entry); entry.requests.catch(() => { - if (this.reviewRequestCache.get(userId) === entry) { - this.reviewRequestCache.delete(userId); + if (this.reviewRequestCache.get(key) === entry) { + this.reviewRequestCache.delete(key); } }); cached = entry; } - const all = await cached.requests; - const shown = filterReviewRequests(all, await this.users.settings(userId)); - const sessions = await this.reviews.latestPrSessions(userId, shown); - return { - requests: shown.map(request => ({ ...request, session: sessions.get(prKey(request.owner, request.repo, request.number)) ?? null })), - hidden: all.length - shown.length, - }; + return cached.requests; + } + + private async withSessions(userId: string, requests: ReviewRequest[]): Promise { + const sessions = await this.reviews.latestPrSessions(userId, requests); + return requests.map(request => ({ ...request, session: sessions.get(prKey(request.owner, request.repo, request.number)) ?? null })); } async requireSession(userId: string, idOrPrefix: string): Promise { diff --git a/packages/server/src/ui-api.ts b/packages/server/src/ui-api.ts index a309f5c9..8a761e44 100644 --- a/packages/server/src/ui-api.ts +++ b/packages/server/src/ui-api.ts @@ -21,10 +21,12 @@ import { describeSession, ServiceError, type ReviewService } from './service.js' import { splitLines } from './anchor.js'; import type { User } from './users.js'; import type { Live } from './live.js'; +import type { Routine } from './routine.js'; export interface UiApiDeps { service: ReviewService; live: Live; + routine: Routine; userFor: (req: Request) => Promise; } @@ -47,7 +49,7 @@ function parsed(res: Response, result: ParseResult): T | null { /** The `/api/*` subset the review UI needs, scoped to one session of the signed-in user. */ export function uiApiRouter(deps: UiApiDeps): Router { - const { service, live } = deps; + const { service, live, routine } = deps; const reviews = service.reviews; const router = express.Router({ mergeParams: true }); router.use(express.json({ limit: '2mb' })); @@ -83,6 +85,13 @@ export function uiApiRouter(deps: UiApiDeps): Router { const author = (user: User): CommentAuthor => ({ name: user.name, type: 'user' }); + const ask = async ({ user, session }: Scoped, threadId: string, commentId: string): Promise => { + await live.ask(user.id, session.id, threadId, commentId); + if (!(await live.status(user.id, session.id)).listening) { + await routine.fire(user.id, session, 'ask'); + } + }; + const sessionMatches = (res: Response, session: ReviewSessionRecord, asked: unknown): boolean => { if (asked !== undefined && asked !== null && asked !== '' && asked !== session.id) { fail(res, 400, 'session does not match this review'); @@ -148,7 +157,8 @@ export function uiApiRouter(deps: UiApiDeps): Router { res.json(await reviews.threadsForSession(user.id, session.id, status)); })); - router.post('/threads', handle(async ({ user, session }, req, res) => { + router.post('/threads', handle(async (scoped, req, res) => { + const { user, session } = scoped; const body = parsed(res, parseCreateThreadRequest(req.body)); if (!body || !sessionMatches(res, session, body.sessionId)) { return; @@ -166,7 +176,7 @@ export function uiApiRouter(deps: UiApiDeps): Router { kind: body.ask ? 'aside' : body.kind ?? 'review', }); if (body.ask) { - await live.ask(user.id, session.id, thread.id, thread.comments[0].id); + await ask(scoped, thread.id, thread.comments[0].id); } res.json(body.ask ? await reviews.getThread(user.id, thread.id) : thread); })); @@ -189,7 +199,7 @@ export function uiApiRouter(deps: UiApiDeps): Router { const kind = body.ask ? 'aside' : body.kind ?? 'review'; const reply = await reviews.addReply(scoped.user.id, thread.id, body.body, author(scoped.user), kind); if (body.ask) { - await live.ask(scoped.user.id, scoped.session.id, thread.id, reply.id); + await ask(scoped, thread.id, reply.id); } res.json(body.ask ? { ...reply, ask: 'pending' } : reply); })); @@ -246,11 +256,12 @@ export function uiApiRouter(deps: UiApiDeps): Router { })); router.get('/live/status', handle(async ({ user, session }, _req, res) => { - res.json((await live.status(user.id, session.id)) satisfies LiveStatusResponse); + res.json({ ...(await live.status(user.id, session.id)), routine: await routine.configured(user.id) } satisfies LiveStatusResponse); })); router.post('/review-request', handle(async ({ user, session }, _req, res) => { await live.queueReview(user.id, session.id); + await routine.fire(user.id, session, 'review'); res.json((await reviews.getSession(user.id, session.id))!.review satisfies ReviewRun); })); diff --git a/packages/server/src/users.ts b/packages/server/src/users.ts index f0153ec1..2b3c7945 100644 --- a/packages/server/src/users.ts +++ b/packages/server/src/users.ts @@ -74,6 +74,42 @@ export class Users { const row = await this.db.one<{ github_token: string | null }>('SELECT github_token FROM users WHERE id = $1', [userId]); return row?.github_token ? decrypt(this.secretKey, row.github_token) : null; } + + /** The Claude routine diffity fires to attend the user's sessions; null as well when its key no longer decrypts. */ + async routine(userId: string): Promise<{ url: string; key: string } | null> { + const row = await this.db.one<{ routine_url: string | null; routine_key: string | null }>( + 'SELECT routine_url, routine_key FROM users WHERE id = $1', + [userId], + ); + const key = row?.routine_key ? decrypt(this.secretKey, row.routine_key) : null; + return row?.routine_url && key ? { url: row.routine_url, key } : null; + } + + /** A null URL clears the routine; an undefined key keeps the stored one. */ + async setRoutine(userId: string, url: string | null, key: string | null | undefined): Promise { + if (url === null) { + await this.db.query('UPDATE users SET routine_url = NULL, routine_key = NULL WHERE id = $1', [userId]); + return; + } + if (key === undefined) { + await this.db.query('UPDATE users SET routine_url = $1 WHERE id = $2', [url, userId]); + return; + } + await this.db.query('UPDATE users SET routine_url = $1, routine_key = $2 WHERE id = $3', [ + url, + key === null ? null : encrypt(this.secretKey, key), + userId, + ]); + } + + /** What the settings page shows of the routine: never the key. */ + async routineSettings(userId: string): Promise<{ url: string | null; keySet: boolean }> { + const row = await this.db.one<{ routine_url: string | null; routine_key: string | null }>( + 'SELECT routine_url, routine_key FROM users WHERE id = $1', + [userId], + ); + return { url: row?.routine_url ?? null, keySet: !!row?.routine_key }; + } } export const WEB_SESSION_MAX_AGE_SECONDS = 30 * 24 * 60 * 60; diff --git a/packages/server/src/web.ts b/packages/server/src/web.ts index 72e7e08b..0b1497ee 100644 --- a/packages/server/src/web.ts +++ b/packages/server/src/web.ts @@ -17,8 +17,8 @@ export const UI_CONTENT_SECURITY_POLICY = [ "connect-src 'self'", ].join('; '); -/** The server's own pages: no script at all, forms only to this origin unless widened for one. */ -export function pageContentSecurityPolicy(extraFormTargets: string[] = []): string { +/** The server's own pages: no script unless the page loads one of this origin's, forms only to this origin unless widened for one. */ +export function pageContentSecurityPolicy(extraFormTargets: string[] = [], selfScripts = false): string { return [ "default-src 'none'", "base-uri 'none'", @@ -26,6 +26,7 @@ export function pageContentSecurityPolicy(extraFormTargets: string[] = []): stri `form-action 'self'${extraFormTargets.map(target => ` ${target}`).join('')}`, "img-src 'self'", "style-src 'unsafe-inline'", + ...selfScripts ? ["script-src 'self'"] : [], ].join('; '); } diff --git a/packages/server/tests/helpers.ts b/packages/server/tests/helpers.ts index 89e72ffc..8df2cdfa 100644 --- a/packages/server/tests/helpers.ts +++ b/packages/server/tests/helpers.ts @@ -166,6 +166,8 @@ export interface FakeGitHub { failReviewWith: number | null; /** What `GET /search/issues` finds waiting for each token's review. */ reviewRequests: Record; + /** What `GET /search/issues` finds authored by each token's own account. */ + ownPulls: Record; /** The `q` of every search. */ searches: string[]; /** Makes `GET /search/issues` answer this status instead. */ @@ -197,6 +199,7 @@ export async function startFakeGitHub(repos: FakeRepo[]): Promise { tokenEndpoint: () => ({ error: 'bad_verification_code' }), failReviewWith: null, reviewRequests: {}, + ownPulls: {}, searches: [], failSearchWith: null, installations: {}, @@ -238,7 +241,8 @@ export async function startFakeGitHub(repos: FakeRepo[]): Promise { send(fake.failSearchWith, { message: 'Validation Failed' }); return; } - const items = (token ? fake.reviewRequests[token] ?? [] : []).map(pr => ({ + const found = (url.searchParams.get('q') ?? '').includes('author:@me') ? fake.ownPulls : fake.reviewRequests; + const items = (token ? found[token] ?? [] : []).map(pr => ({ number: pr.number, title: pr.title, html_url: `https://github.com/${pr.owner}/${pr.repo}/pull/${pr.number}`, diff --git a/packages/server/tests/http.test.ts b/packages/server/tests/http.test.ts index 6f6fb2c4..8a011b83 100644 --- a/packages/server/tests/http.test.ts +++ b/packages/server/tests/http.test.ts @@ -159,6 +159,36 @@ describe('web pages', () => { expect(await server.users.gitHubToken(aliceId)).toBeNull(); }); + it('keeps the routine’s key encrypted and never shows it, keeps it on a save without one, and removes both on request', async () => { + const save = (body: string) => api('/settings/routine', { + method: 'POST', + cookie: aliceCookie, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }); + expect((await api('/settings', { cookie: aliceCookie })).body).toContain('No key yet'); + expect((await save('url=http%3A%2F%2Finsecure.example%2Ffire&key=sk-routine')).status).toBe(400); + expect(await server.users.routine(aliceId)).toBeNull(); + + const saved = await save('url=+https%3A%2F%2Fapi.example%2Ffire+&key=sk-routine'); + expect(saved.status).toBe(303); + expect(saved.headers.get('location')).toBe('/settings'); + expect(await server.users.routine(aliceId)).toEqual({ url: 'https://api.example/fire', key: 'sk-routine' }); + expect(JSON.stringify(await server.db.query('SELECT routine_key FROM users'))).not.toContain('sk-routine'); + const settings = (await api('/settings', { cookie: aliceCookie })).body as string; + expect(settings).toContain('Key is set'); + expect(settings).toContain('value="https://api.example/fire"'); + expect(settings).not.toContain('sk-routine'); + expect(await server.users.routine(bobId)).toBeNull(); + + await save('url=https%3A%2F%2Fapi.example%2Fother&key='); + expect(await server.users.routine(aliceId)).toEqual({ url: 'https://api.example/other', key: 'sk-routine' }); + + await save('clear=1'); + expect(await server.users.routine(aliceId)).toBeNull(); + expect(await server.users.routineSettings(aliceId)).toEqual({ url: null, keySet: false }); + }); + it('signs out', async () => { const cookie = await login(server.base, 'carol@example.com'); expect((await api('/', { cookie })).status).toBe(200); @@ -194,9 +224,9 @@ describe('pull requests awaiting review', () => { try { const page = (await api('/', { cookie: aliceCookie })).body as string; expect(page).toContain('acme/widgets#1'); - expect(page).toContain(`open · ${fixture.head1.slice(0, 7)}`); + expect(page).toContain(`open · ${fixture.head1.slice(0, 7)}`); expect(page).toContain('2026-10-03 09:15'); - expect(page).toMatch(/action="\/sessions">\s*\s*/); + expect(page).toMatch(/action="\/sessions" target="_blank">\s*\s*/); expect(page).not.toContain('Release 1.2'); expect(page).toContain('(1 hidden by your'); @@ -209,6 +239,92 @@ describe('pull requests awaiting review', () => { } }); + it('offers to hand a pull request to the agent when a routine is configured, with nobody listening', async () => { + github.reviewRequests['alice-token-routine'] = [ + { owner: 'acme', repo: 'widgets', number: 1, title: 'Change line ten', author: 'octocat', updatedAt: '2026-10-03T09:15:00Z' }, + { owner: 'acme', repo: 'widgets', number: 2, title: 'Rename a file', author: 'carol', updatedAt: '2026-10-02T09:00:00Z' }, + ]; + await server.users.setGitHubToken(aliceId, 'alice-token-routine'); + const userStatus = async () => (await api('/api/live/status', { cookie: aliceCookie })).body; + const sessionStatus = async () => (await api(`/s/${session.id}/api/live/status`, { cookie: aliceCookie })).body; + try { + const without = (await api('/', { cookie: aliceCookie })).body as string; + expect(without).not.toContain('Create and review'); + expect(without).not.toContain(''); + expect(without).toContain(''); + expect(await userStatus()).toMatchObject({ listening: false, routine: false }); + expect(await sessionStatus()).toMatchObject({ listening: false, routine: false }); + + await server.users.setRoutine(aliceId, 'https://api.example/fire', 'sk-routine'); + const reachable = (await api('/', { cookie: aliceCookie })).body as string; + expect(reachable).not.toContain('Agent listening'); + expect(reachable).toMatch(/name="pr" value="2">\s*`); + expect(await userStatus()).toMatchObject({ listening: false, routine: true }); + expect(await sessionStatus()).toMatchObject({ listening: false, routine: true }); + expect((await api('/api/live/status', { cookie: bobCookie })).body.routine).toBe(false); + } finally { + await server.users.setRoutine(aliceId, null, null); + await server.users.setGitHubToken(aliceId, null); + } + }); + + it('lists the user’s own open pull requests, drafts marked, apart from the review queue', async () => { + github.reviewRequests['alice-token-own'] = [ + { owner: 'acme', repo: 'widgets', number: 2, title: 'Rename a file', author: 'carol', updatedAt: '2026-10-02T09:00:00Z' }, + ]; + github.ownPulls['alice-token-own'] = [ + { owner: 'acme', repo: 'widgets', number: 1, title: 'Change line ten', author: 'alice', updatedAt: '2026-10-03T09:15:00Z' }, + { owner: 'acme', repo: 'widgets', number: 5, title: 'Work in progress', author: 'alice', draft: true, updatedAt: '2026-10-04T09:15:00Z' }, + ]; + await server.users.setGitHubToken(aliceId, 'alice-token-own'); + try { + const page = (await api('/', { cookie: aliceCookie })).body as string; + const review = page.slice(page.indexOf('Awaiting your review'), page.indexOf('Your open pull requests')); + const own = page.slice(page.indexOf('Your open pull requests'), page.indexOf('Your review sessions')); + expect(review).toContain('acme/widgets#2'); + expect(review).not.toContain('acme/widgets#1'); + expect(review).not.toContain('Work in progress'); + expect(own).toContain('acme/widgets#1'); + expect(own).toContain('Work in progress draft'); + expect(own).toContain(`open · ${fixture.head1.slice(0, 7)}`); + expect(own).not.toContain('acme/widgets#2'); + expect(github.searches).toContain('is:pr is:open archived:false author:@me'); + + github.ownPulls['alice-token-own-none'] = []; + await server.users.setGitHubToken(aliceId, 'alice-token-own-none'); + expect((await api('/', { cookie: aliceCookie })).body).toContain('You have no open pull requests.'); + } finally { + await server.users.setGitHubToken(aliceId, null); + } + expect((await api('/', { cookie: aliceCookie })).body).toContain('Connect GitHub on Settings to see your open pull requests.'); + }); + + it('opens sessions in a new tab, folds each section, and allows only its own script', async () => { + github.reviewRequests['alice-token-tabs'] = [ + { owner: 'acme', repo: 'widgets', number: 2, title: 'Rename a file', author: 'carol', updatedAt: '2026-10-02T09:00:00Z' }, + ]; + await server.users.setGitHubToken(aliceId, 'alice-token-tabs'); + try { + const res = await api('/', { cookie: aliceCookie }); + const page = res.body as string; + expect(page).toContain(``); + expect(page).not.toMatch(//); + expect(page).toContain('
'); + expect(page.match(/

/g)).toHaveLength(3); + expect(page).toContain(''); + const csp = res.headers.get('content-security-policy')!; + expect(csp).toContain("script-src 'self'"); + expect(csp).not.toMatch(/script-src[^;]*unsafe/); + const script = await api('/sections.js'); + expect(script.headers.get('content-type')).toContain('javascript'); + expect(script.body).toContain('localStorage'); + expect((await api('/settings', { cookie: aliceCookie })).headers.get('content-security-policy')).not.toContain('script-src'); + } finally { + await server.users.setGitHubToken(aliceId, null); + } + }); + it('saves the filters from the settings form, and only from this site', async () => { expect(await form('/settings/review-requests', { skipTitles: 'Bump ', includeDrafts: '1' }, aliceCookie, { 'Sec-Fetch-Site': 'cross-site' })) .toMatchObject({ status: 403 }); diff --git a/packages/server/tests/live.test.ts b/packages/server/tests/live.test.ts index 3a5d7237..ecfc79a5 100644 --- a/packages/server/tests/live.test.ts +++ b/packages/server/tests/live.test.ts @@ -3,7 +3,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import type { CommentThread, RepoInfoResponse } from '@diffity/api'; -import { CLAIM_TTL_MS, LISTENING_WINDOW_MS, LIVE_TOKEN_TTL_MS, Live, REVIEW_STALE_MS } from '../src/live.js'; +import { CLAIM_TTL_MS, LISTENING_WINDOW_MS, LIVE_TOKEN_TTL_MS, Live, REVIEW_STALE_MS, SESSION_TOKEN_TTL_MS } from '../src/live.js'; import { Reviews } from '../src/reviews.js'; import { Users } from '../src/users.js'; import type { Db } from '../src/db.js'; @@ -133,6 +133,24 @@ describe('the request queue', () => { expect(await live.verifyToken(scoped.token, null)).toBeNull(); }); + it('accepts a session token as one for its session, until it expires, and a wait token never as one', async () => { + const { token, expiresAt } = await live.issueSessionToken(alice, sessionId); + expect(expiresAt - Date.now()).toBeGreaterThan(SESSION_TOKEN_TTL_MS - 60_000); + expect(await live.verifySessionToken(token)).toEqual({ userId: alice, sessionId, expiresAt }); + expect(await live.verifySessionToken(token, expiresAt)).toBeNull(); + expect(await live.verifySessionToken('made-up')).toBeNull(); + expect((await db.one<{ scope: string }>('SELECT scope FROM live_tokens WHERE session_id = $1 AND expires_at = $2', [sessionId, expiresAt]))?.scope) + .toBe('session'); + + expect(await live.verifyToken(token, sessionId)).toBe(alice); + expect(await live.verifyToken(token, otherSessionId)).toBeNull(); + expect(await live.verifyToken(token, null)).toBeNull(); + + const wait = await live.issueToken(alice, sessionId); + expect(await live.verifySessionToken(wait.token)).toBeNull(); + expect(await live.verifySessionToken((await live.issueToken(alice, null)).token)).toBeNull(); + }); + it('queues one review per session at a time, and review_done settles it', async () => { expect(await live.queueReview(alice, otherSessionId)).toBe(true); expect(await live.queueReview(alice, otherSessionId)).toBe(false); @@ -192,7 +210,7 @@ describe('the request queue', () => { expect((await live.userStatus(alice, now)).listening).toBe(false); await live.recordPoll({ userId: alice, sessionId: null }, now); - expect(await live.status(alice, otherSessionId, now)).toEqual({ listening: true, lastPollAt: new Date(now).toISOString(), working: 0 }); + expect(await live.status(alice, otherSessionId, now)).toEqual({ listening: true, lastPollAt: new Date(now).toISOString(), working: 0, calledAt: null }); expect((await live.userStatus(alice, now)).listening).toBe(true); expect((await live.userStatus(alice, now + LISTENING_WINDOW_MS)).listening).toBe(false); expect((await live.userStatus(bob, now)).listening).toBe(false); @@ -577,7 +595,7 @@ describe('the attendant over HTTP', () => { try { const status = () => fetch(`${server.base}/api/live/status`, { headers: { cookie: aliceCookie } }).then(res => res.json()); expect((await fetch(`${server.base}/api/live/status`)).status).toBe(401); - expect(await status()).toEqual({ listening: false, lastPollAt: null, working: 0 }); + expect(await status()).toEqual({ listening: false, lastPollAt: null, working: 0, calledAt: null, routine: false }); const before = await (await fetch(`${server.base}/`, { headers: { cookie: aliceCookie } })).text(); expect(before).not.toContain('Agent listening'); expect(before).toContain(''); diff --git a/packages/server/tests/mcp.test.ts b/packages/server/tests/mcp.test.ts index f6a4894f..0b0b5130 100644 --- a/packages/server/tests/mcp.test.ts +++ b/packages/server/tests/mcp.test.ts @@ -346,6 +346,68 @@ describe('automation tokens', () => { }); }); +describe('session tokens', () => { + it('work on their one session through the review tools, and on nothing else', async () => { + const aliceId = (await server.users.findOrCreate('alice@example.com')).id; + const { session } = await ok<{ session: string }>(alice, 'create_session', { repo: 'acme/widgets', base: fixture.base, head: fixture.head1 }); + const other = await ok<{ session: string }>(alice, 'create_session', { repo: 'acme/widgets', base: fixture.base, head: fixture.head2 }); + const otherThread = await ok<{ thread: string }>(alice, 'comment', { session: other.session, file: 'added.ts', line: 1, body: 'Elsewhere' }); + const otherTour = await ok<{ tour: string }>(alice, 'tour_start', { session: other.session, topic: 'Elsewhere' }); + const { token } = await server.live.issueSessionToken(aliceId, session); + const attendant = await mcpClient(token); + try { + const names = (await attendant.listTools()).tools.map(tool => tool.name); + for (const removed of [ + 'create_session', 'list_sessions', 'list_review_requests', 'get_diff', 'get_file', + 'automation_token', 'automation_tokens', 'automation_token_revoke', + ]) { + expect(names).not.toContain(removed); + } + expect(names).toEqual(expect.arrayContaining(['comment', 'list_comments', 'review_start', 'review_done', 'post_review', 'live_token'])); + + await ok(attendant, 'review_start', { session: session.slice(0, 8) }); + const finding = await ok<{ thread: string }>(attendant, 'comment', { session, file: 'src.ts', line: 10, body: 'From the attendant' }); + const threads = await ok<{ id: string; comments: { author: { name: string; type: string } }[] }[]>(attendant, 'list_comments', { session }); + expect(threads.find(t => t.id === finding.thread)?.comments[0].author).toEqual({ name: 'Attendant', type: 'agent' }); + await ok(attendant, 'review_done', { session }); + expect(await failed(attendant, 'post_review', { session, event: 'COMMENT' })).toContain('not configured on this server'); + + const only = `This token is for session ${session.slice(0, 8)} only`; + expect(await failed(attendant, 'comment', { session: other.session, file: 'added.ts', line: 1, body: 'x' })).toBe(only); + expect(await failed(attendant, 'list_comments', { session: other.session })).toBe(only); + expect(await failed(attendant, 'review_start', { session: other.session })).toBe(only); + expect(await failed(attendant, 'review_done', { session: other.session })).toBe(only); + expect(await failed(attendant, 'post_review', { session: other.session, event: 'COMMENT' })).toBe(only); + expect(await failed(attendant, 'reply', { id: otherThread.thread, body: 'x' })).toContain('belongs to another session'); + expect(await failed(attendant, 'resolve', { id: otherThread.thread })).toContain('belongs to another session'); + expect(await failed(attendant, 'amend', { id: otherThread.thread, body: 'x' })).toContain('belongs to another session'); + expect(await failed(attendant, 'tour_done', { tour: otherTour.tour })).toBe(only); + + const waiting = await ok<{ session: string; command: string; pollUrl: string; token: string }>(attendant, 'live_token', { session: other.session }); + expect(waiting.session).toBe(session); + expect(waiting.pollUrl).toBe(`${server.base}/live/await?session=${session}&wait=25`); + expect(waiting.command).toContain(waiting.pollUrl); + } finally { + await attendant.close(); + await ok(alice, 'tour_delete', { tour: otherTour.tour }); + } + }); + + it('wait on their session at /live/await', async () => { + const aliceId = (await server.users.findOrCreate('alice@example.com')).id; + const { session } = await ok<{ session: string }>(alice, 'create_session', { repo: 'acme/widgets', base: fixture.base, head: fixture.head1 }); + const { token } = await server.live.issueSessionToken(aliceId, session); + const thread = await server.service.reviews.createThread({ + userId: aliceId, sessionId: session, filePath: 'src.ts', side: 'new', startLine: 10, endLine: 10, body: 'Why?', author: { name: 'Alice', type: 'user' }, + }); + await server.live.ask(aliceId, session, thread.id, thread.comments[0].id); + const res = await fetch(`${server.base}/live/await?session=${session}&wait=0`, { headers: { Authorization: `Bearer ${token}` } }); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ kind: 'ask', session, thread: thread.id, question: 'Why?' }); + await server.live.answer(aliceId, thread.id); + }); +}); + describe('post_review', () => { it('answers that bot posting is not configured on a server without the App key', async () => { const { session } = await ok<{ session: string }>(alice, 'create_session', { repo: 'acme/widgets', pr: 1 }); diff --git a/packages/server/tests/routine.test.ts b/packages/server/tests/routine.test.ts new file mode 100644 index 00000000..d7bcbfc7 --- /dev/null +++ b/packages/server/tests/routine.test.ts @@ -0,0 +1,188 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { LiveStatusResponse } from '@diffity/api'; +import { + fakeUiDir, + login, + makeFixture, + removeDir, + startFakeGitHub, + startTestServer, + tempDir, + type FakeGitHub, + type Fixture, + type TestServer, +} from './helpers.js'; + +interface Fired { + url: string; + headers: Headers; + text: string; +} + +let fixture: Fixture; +let github: FakeGitHub; +let dataDir: string; +let server: TestServer; +let aliceCookie: string; +let bobCookie: string; +let aliceId: string; +let bobId: string; +let fired: Fired[]; +let answer: number; + +const routineFetch = (async (input: string | URL | Request, init?: RequestInit) => { + fired.push({ url: String(input), headers: new Headers(init?.headers), text: (JSON.parse(String(init?.body)) as { text: string }).text }); + return new Response('{}', { status: answer }); +}) as typeof fetch; + +const sameSite = (cookie: string) => ({ cookie, 'Sec-Fetch-Site': 'same-origin' }); + +const createAndReview = (cookie: string) => fetch(`${server.base}/sessions`, { + method: 'POST', + redirect: 'manual', + headers: { ...sameSite(cookie), 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ repo: 'acme/widgets', pr: '1', review: '1' }).toString(), +}); + +const shasSession = async (userId: string, base: string, head: string) => + (await server.service.createSession(userId, { repo: 'acme/widgets', base, head })).session; + +const ask = (session: string, cookie = aliceCookie) => fetch(`${server.base}/s/${session}/api/threads`, { + method: 'POST', + headers: { ...sameSite(cookie), 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionId: session, filePath: 'src.ts', side: 'new', startLine: 10, endLine: 10, body: 'Why?', author: { name: 'You', type: 'user' }, ask: true }), +}); + +const firedAt = async (session: string) => + (await server.db.one<{ routine_fired_at: number | null }>('SELECT routine_fired_at FROM sessions WHERE id = $1', [session]))!.routine_fired_at; + +/** `diffity session repo pr base head kind url token ` */ +const fields = (text: string): Record => { + const words = text.split(' '); + expect(words[0]).toBe('diffity'); + const pairs: Record = {}; + for (let i = 1; i < words.length; i += 2) { + pairs[words[i]] = words[i + 1]; + } + return pairs; +}; + +beforeAll(async () => { + fixture = makeFixture(); + github = await startFakeGitHub([ + { + owner: 'acme', + name: 'widgets', + private: false, + tokens: [], + pulls: { 1: { title: 'Change line ten', baseSha: fixture.mainTip, headSha: fixture.head1 } }, + }, + ]); + dataDir = tempDir('routine'); + server = await startTestServer(dataDir, github.url, { remoteUrl: fixture.remoteUrl, uiDir: fakeUiDir(dataDir), routineFetch }); + aliceCookie = await login(server.base, 'alice@example.com'); + bobCookie = await login(server.base, 'bob@example.com'); + aliceId = (await server.users.findOrCreate('alice@example.com')).id; + bobId = (await server.users.findOrCreate('bob@example.com')).id; + await server.users.setRoutine(aliceId, 'https://api.example/fire', 'sk-routine'); +}); + +beforeEach(() => { + fired = []; + answer = 200; +}); + +afterAll(async () => { + await server.close(); + await github.close(); + removeDir(fixture.root); + removeDir(dataDir); +}); + +describe('calling the routine', () => { + it('fires once for a session created and reviewed from the page, with a token for that session only', async () => { + const created = await createAndReview(aliceCookie); + expect(created.status).toBe(303); + const session = /^\/s\/([^/]+)\/$/.exec(created.headers.get('location')!)![1]; + + expect(fired).toHaveLength(1); + const [call] = fired; + expect(call.url).toBe('https://api.example/fire'); + expect(call.headers.get('authorization')).toBe('Bearer sk-routine'); + expect(call.headers.get('anthropic-beta')).toBe('experimental-cc-routine-2026-04-01'); + expect(call.headers.get('anthropic-version')).toBe('2023-06-01'); + expect(call.headers.get('content-type')).toBe('application/json'); + expect(call.text).not.toContain('\n'); + const payload = fields(call.text); + expect(payload).toEqual({ + session, + repo: 'acme/widgets', + pr: '1', + base: fixture.base, + head: fixture.head1, + kind: 'review', + url: `${server.base}/s/${session}/`, + token: expect.any(String), + }); + expect(await server.live.verifySessionToken(payload.token)).toMatchObject({ userId: aliceId, sessionId: session }); + + const again = await fetch(`${server.base}/sessions/${session}/review-request`, { method: 'POST', redirect: 'manual', headers: sameSite(aliceCookie) }); + expect(again.status).toBe(303); + const fromPage = await fetch(`${server.base}/s/${session}/api/review-request`, { method: 'POST', headers: sameSite(aliceCookie) }); + expect(fromPage.status).toBe(200); + expect(fired).toHaveLength(1); + }); + + it('fires for a question nobody is listening for, and the page says the agent was called', async () => { + const session = await shasSession(aliceId, fixture.mainTip, fixture.head1); + const status = async () => + (await fetch(`${server.base}/s/${session.id}/api/live/status`, { headers: { cookie: aliceCookie } })).json() as Promise; + expect((await status()).calledAt).toBeNull(); + + expect((await ask(session.id)).status).toBe(200); + expect(fired).toHaveLength(1); + expect(fields(fired[0].text)).toMatchObject({ session: session.id, pr: '-', base: fixture.mainTip, head: fixture.head1, kind: 'ask' }); + const after = await status(); + expect(after.listening).toBe(false); + expect(Date.now() - Date.parse(after.calledAt!)).toBeLessThan(5000); + + expect((await ask(session.id)).status).toBe(200); + expect(fired).toHaveLength(1); + }); + + it('does not fire for a question an agent is listening for', async () => { + const session = await shasSession(aliceId, fixture.base, fixture.head2); + await server.live.recordPoll({ userId: aliceId, sessionId: session.id }); + expect((await ask(session.id)).status).toBe(200); + expect(fired).toHaveLength(0); + expect(await firedAt(session.id)).toBeNull(); + }); + + it('answers the request as usual when the routine fails, and lets the next one try again', async () => { + const session = await shasSession(aliceId, fixture.base, fixture.mainTip); + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + answer = 500; + expect((await ask(session.id)).status).toBe(200); + expect(fired).toHaveLength(1); + expect(await firedAt(session.id)).toBeNull(); + expect(errors).toHaveBeenCalledWith(`routine fire failed for session ${session.id}: 500`); + + answer = 200; + expect((await ask(session.id)).status).toBe(200); + expect(fired).toHaveLength(2); + expect(await firedAt(session.id)).not.toBeNull(); + } finally { + errors.mockRestore(); + } + }); + + it('fires nothing for a user without a routine', async () => { + const created = await createAndReview(bobCookie); + expect(created.status).toBe(303); + const session = await shasSession(bobId, fixture.base, fixture.head1); + expect((await ask(session.id, bobCookie)).status).toBe(200); + expect(fired).toHaveLength(0); + expect(await firedAt(session.id)).toBeNull(); + }); +}); diff --git a/packages/ui/src/components/comments/comment-form.tsx b/packages/ui/src/components/comments/comment-form.tsx index e6af89c1..17654b82 100644 --- a/packages/ui/src/components/comments/comment-form.tsx +++ b/packages/ui/src/components/comments/comment-form.tsx @@ -1,5 +1,5 @@ import { useState, useRef, useEffect, useContext } from 'react'; -import { AgentListeningContext } from '../../lib/agent-listening'; +import { AgentCalledContext, AgentListeningContext, AgentReachableContext } from '../../lib/agent-listening'; interface CommentFormProps { onSubmit: (body: string) => void; @@ -18,6 +18,8 @@ export function CommentForm(props: CommentFormProps) { autoFocus = true, lineLabel, onAsk, } = props; const listening = useContext(AgentListeningContext); + const called = useContext(AgentCalledContext); + const reachable = useContext(AgentReachableContext) || listening; const [body, setBody] = useState(''); const textareaRef = useRef(null); @@ -76,10 +78,15 @@ export function CommentForm(props: CommentFormProps) { Cancel {onAsk && ( - +