Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/agents/definitions/alerting.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,12 @@ prompts:
2. Read the relevant source files to understand context and confirm the root cause
3. Summarize: what failed, why, and which code path is responsible
<% if (it.backlogListId) { %>
4. Create a bug fix work item in the backlog (list/status ID: <%= it.backlogListId %>) with:
4. Create a bug fix investigation work item in the backlog (list/status ID: <%= it.backlogListId %>) with:
- Title: short, actionable description (e.g. "Fix: NullPointerException in PaymentService.charge")
- Description: root cause, error details, affected file/function, link to the alert issue
<% } else if (it.workItemId) { %>
4. Post a comment on the current work item (<%= it.workItemId %>) summarising the investigation:
root cause, affected file/function, and recommended fix approach
<% } %>
5. Call Finish when done

Expand All @@ -69,3 +72,4 @@ hint: |
Focus on frames from application code (not third-party library frames).
Check the event timeline/breadcrumbs for the action or request that preceded the failure.
Keep the bug work item description concise and actionable.
If no backlog list is configured, post investigation findings as a comment on the triggering work item.
9 changes: 9 additions & 0 deletions src/agents/shared/promptContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ function getPromptTerminology(pmType: string | undefined) {
* Shared by the llmist agent lifecycle (agents/base.ts) and the adapter
* (backends/adapter.ts) so both backends use consistent prompt context
* building logic including PM-type normalization and work item noun i18n.
*
* @param alertingResultsContainerId - Optional PM container ID from Sentry integration config.
* Used as a fallback `backlogListId` when no PM backlog is configured on the project.
* Populated by `secretOrchestrator` for alerting agent runs.
*/
export function buildPromptContext(
workItemId: string | undefined,
Expand All @@ -64,17 +68,22 @@ export function buildPromptContext(
originalWorkItemUrl: string;
detectedAgentType: string;
},
alertingResultsContainerId?: string,
): PromptContext {
const pmProvider = getPMProviderOrNull();
const listIds = getListIds(project);
const terminology = getPromptTerminology(pmProvider?.type);

// Fall back to the Sentry-configured results container when no PM backlog is set.
const backlogListId = listIds.backlogListId ?? alertingResultsContainerId;

return {
workItemId,
workItemUrl: workItemId && pmProvider ? pmProvider.getWorkItemUrl(workItemId) : undefined,
projectId: project.id,
baseBranch: project.baseBranch,
...listIds,
backlogListId,
pmType: pmProvider?.type,
...terminology,
maxInFlightItems: project.maxInFlightItems ?? 1,
Expand Down
15 changes: 15 additions & 0 deletions src/backends/secretOrchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { createAgentLogger } from '../agents/utils/logging.js';
import { mergeEngineSettings } from '../config/engineSettings.js';
import { loadPartials } from '../db/repositories/partialsRepository.js';
import { withGitHubToken } from '../github/client.js';
import { getSentryIntegrationConfig } from '../sentry/integration.js';
import type { AgentInput, CascadeConfig, ProjectConfig } from '../types/index.js';
import { getDashboardUrl } from '../utils/runLink.js';
import { createNativeToolRuntimeArtifacts } from './nativeToolRuntime.js';
Expand Down Expand Up @@ -59,11 +60,25 @@ export async function buildExecutionPlan(
}
: undefined;

// For alerting agents, look up Sentry `resultsContainerId` as a fallback
// backlogListId when no PM backlog status/list is configured on the project.
let alertingResultsContainerId: string | undefined;
if (agentType === 'alerting') {
try {
const sentryConfig = await getSentryIntegrationConfig(project.id);
alertingResultsContainerId = sentryConfig?.resultsContainerId;
} catch {
// Non-fatal — proceed without the fallback container
}
}

const promptContext: PromptContext = buildPromptContext(
workItemId,
project,
input.triggerType,
prContext,
undefined,
alertingResultsContainerId,
);

// Load DB partials for template include resolution
Expand Down
8 changes: 8 additions & 0 deletions src/sentry/integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ import { getIntegrationByProjectAndCategory } from '../db/repositories/integrati
export interface SentryIntegrationConfig {
/** Sentry organization slug (e.g. "my-company") */
organizationSlug: string;
/**
* PM container ID where the alerting agent creates investigation work items.
* Maps to `backlogListId` in the prompt context when no PM backlog is configured.
*/
resultsContainerId?: string;
}

// ============================================================================
Expand All @@ -35,5 +40,8 @@ export async function getSentryIntegrationConfig(

return {
organizationSlug: config.organizationSlug,
...(typeof config.resultsContainerId === 'string'
? { resultsContainerId: config.resultsContainerId }
: {}),
};
}
81 changes: 81 additions & 0 deletions tests/unit/agents/shared/promptContext.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,87 @@ describe('buildPromptContext', () => {
});
});

describe('with alertingResultsContainerId fallback', () => {
beforeEach(() => {
const mockProvider = createMockPMProvider();
mockProvider.type = 'trello';
mockProvider.getWorkItemUrl = vi.fn((id: string) => `https://trello.com/c/${id}`);
mockGetPMProvider.mockReturnValue(mockProvider);
});

it('uses alertingResultsContainerId as backlogListId when PM backlog is not set', () => {
const projectWithoutBacklog = makeProject({
trello: {
boardId: 'board1',
lists: {
splitting: 'list1',
planning: 'list2',
todo: 'list3',
// no backlog
inProgress: 'list-in-progress',
inReview: 'list-in-review',
merged: 'list-merged',
},
labels: { readyToProcess: 'label1', processed: 'label2' },
},
});
const ctx = buildPromptContext(
'card123',
projectWithoutBacklog as never,
undefined,
undefined,
undefined,
'sentry-container-id',
);
expect(ctx.backlogListId).toBe('sentry-container-id');
});

it('PM backlogListId takes precedence over alertingResultsContainerId', () => {
const ctx = buildPromptContext(
'card123',
makeProject() as never,
undefined,
undefined,
undefined,
'sentry-container-id',
);
// makeProject has trello.lists.backlog = 'list-backlog'
expect(ctx.backlogListId).toBe('list-backlog');
});

it('alertingResultsContainerId is ignored when it is undefined', () => {
const ctx = buildPromptContext(
'card123',
makeProject() as never,
undefined,
undefined,
undefined,
undefined,
);
// still gets backlog from PM config
expect(ctx.backlogListId).toBe('list-backlog');
});

it('backlogListId remains undefined when neither PM backlog nor alertingResultsContainerId is set', () => {
const projectWithoutBacklog = makeProject({
trello: {
boardId: 'board1',
lists: {
splitting: 'list1',
planning: 'list2',
todo: 'list3',
inProgress: 'list-in-progress',
inReview: 'list-in-review',
merged: 'list-merged',
},
labels: { readyToProcess: 'label1', processed: 'label2' },
},
});
const ctx = buildPromptContext('card123', projectWithoutBacklog as never);
expect(ctx.backlogListId).toBeUndefined();
});
});

describe('without PM provider (no PM context — e.g. debug agent from dashboard)', () => {
beforeEach(() => {
mockGetPMProvider.mockReturnValue(null);
Expand Down
148 changes: 147 additions & 1 deletion tests/unit/backends/secretOrchestrator.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,75 @@
/* biome-ignore lint/suspicious/noExplicitAny: test mocks */
import { beforeEach, describe, expect, it, vi } from 'vitest';

vi.mock('../../../src/utils/runLink.js', () => ({
getDashboardUrl: vi.fn(),
}));

import { injectRunLinkSecrets } from '../../../src/backends/secretOrchestrator.js';
// Mock everything that buildExecutionPlan might call
vi.mock('../../../src/agents/shared/modelResolution.js', () => ({
resolveModelConfig: vi.fn().mockResolvedValue({
systemPrompt: 'system',
taskPrompt: 'task',
model: 'claude',
maxIterations: 10,
}),
}));

vi.mock('../../../src/agents/shared/promptContext.js', () => ({
buildPromptContext: vi.fn().mockReturnValue({}),
}));

vi.mock('../../../src/db/repositories/partialsRepository.js', () => ({
loadPartials: vi.fn().mockResolvedValue(new Map()),
}));

vi.mock('../../../src/sentry/integration.js', () => ({
getSentryIntegrationConfig: vi.fn(),
}));

vi.mock('../../../src/agents/definitions/profiles.js', () => ({
getAgentProfile: vi.fn().mockReturnValue({
fetchContext: vi.fn().mockResolvedValue({}),
finishHooks: {},
filterTools: vi.fn().mockReturnValue([]),
}),
}));

vi.mock('../../../src/agents/definitions/toolManifests.js', () => ({
getToolManifests: vi.fn().mockReturnValue([]),
}));

vi.mock('../../../src/backends/registry.js', () => ({
isNativeToolEngineDefinition: vi.fn().mockReturnValue(false),
}));

vi.mock('../../../src/agents/definitions/index.js', () => ({
needsGitStateStopHooks: vi.fn().mockReturnValue(false),
}));

vi.mock('../../../src/backends/secretBuilder.js', () => ({
augmentProjectSecrets: vi.fn().mockResolvedValue({}),
resolveGitHubToken: vi.fn(),
injectGitHubAckCommentId: vi.fn(),
injectProgressCommentId: vi.fn(),
}));

vi.mock('../../../src/backends/sidecarManager.js', () => ({
createCompletionArtifacts: vi.fn().mockReturnValue({}),
}));

import { buildPromptContext } from '../../../src/agents/shared/promptContext.js';
import {
buildExecutionPlan,
injectRunLinkSecrets,
} from '../../../src/backends/secretOrchestrator.js';
import { getSentryIntegrationConfig } from '../../../src/sentry/integration.js';
import type { ProjectConfig } from '../../../src/types/index.js';
import { getDashboardUrl } from '../../../src/utils/runLink.js';

const mockGetDashboardUrl = vi.mocked(getDashboardUrl);
const mockGetSentryIntegrationConfig = vi.mocked(getSentryIntegrationConfig);
const mockBuildPromptContext = vi.mocked(buildPromptContext);

function makeProject(overrides?: Partial<ProjectConfig>): ProjectConfig {
return {
Expand All @@ -24,6 +85,91 @@ function makeProject(overrides?: Partial<ProjectConfig>): ProjectConfig {

beforeEach(() => {
mockGetDashboardUrl.mockReturnValue(undefined);
vi.clearAllMocks();
});

describe('buildExecutionPlan', () => {
it('fetches sentry config for alerting agent', async () => {
mockGetSentryIntegrationConfig.mockResolvedValueOnce({
organizationSlug: 'org',
resultsContainerId: 'sentry-container-123',
});

const project = makeProject();
await buildExecutionPlan(
'alerting',
{ project, config: {}, triggerType: 'sentry:issue-created' } as unknown as any,
'/repo',
{} as unknown as any,
{} as unknown as any,
'token',
false,
'claude-code',
{} as unknown as any,
);

expect(mockGetSentryIntegrationConfig).toHaveBeenCalledWith('test-project');
expect(mockBuildPromptContext).toHaveBeenCalledWith(
undefined,
project,
'sentry:issue-created',
undefined,
undefined,
'sentry-container-123',
);
});

it('does not fetch sentry config for non-alerting agent', async () => {
const project = makeProject();
await buildExecutionPlan(
'implementation',
{ project, config: {}, triggerType: 'manual' } as unknown as any,
'/repo',
{} as unknown as any,
{} as unknown as any,
'token',
false,
'claude-code',
{} as unknown as any,
);

expect(mockGetSentryIntegrationConfig).not.toHaveBeenCalled();
expect(mockBuildPromptContext).toHaveBeenCalledWith(
undefined,
project,
'manual',
undefined,
undefined,
undefined,
);
});

it('handles sentry config failure gracefully', async () => {
mockGetSentryIntegrationConfig.mockRejectedValueOnce(new Error('DB failure'));

const project = makeProject();
await buildExecutionPlan(
'alerting',
{ project, config: {}, triggerType: 'sentry:issue-created' } as unknown as any,
'/repo',
{} as unknown as any,
{} as unknown as any,
'token',
false,
'claude-code',
{} as unknown as any,
);

expect(mockGetSentryIntegrationConfig).toHaveBeenCalledWith('test-project');
expect(mockBuildPromptContext).toHaveBeenCalledWith(
undefined,
project,
'sentry:issue-created',
undefined,
undefined,
undefined,
);
});
});

describe('injectRunLinkSecrets', () => {
Expand Down
Loading
Loading