Skip to content

Commit 975e333

Browse files
authored
fix(slack-bot): require structured classifier output (#70)
1 parent 510f2c8 commit 975e333

File tree

2 files changed

+268
-107
lines changed

2 files changed

+268
-107
lines changed
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import type { Env, RepoConfig } from "../types";
3+
4+
const {
5+
mockMessagesCreate,
6+
mockGetAvailableRepos,
7+
mockBuildRepoDescriptions,
8+
mockGetReposByChannel,
9+
} = vi.hoisted(() => ({
10+
mockMessagesCreate: vi.fn(),
11+
mockGetAvailableRepos: vi.fn(),
12+
mockBuildRepoDescriptions: vi.fn(),
13+
mockGetReposByChannel: vi.fn(),
14+
}));
15+
16+
vi.mock("@anthropic-ai/sdk", () => ({
17+
default: vi.fn().mockImplementation(() => ({
18+
messages: {
19+
create: mockMessagesCreate,
20+
},
21+
})),
22+
}));
23+
24+
vi.mock("./repos", () => ({
25+
getAvailableRepos: mockGetAvailableRepos,
26+
buildRepoDescriptions: mockBuildRepoDescriptions,
27+
getReposByChannel: mockGetReposByChannel,
28+
}));
29+
30+
import { RepoClassifier } from "./index";
31+
32+
const TEST_REPOS: RepoConfig[] = [
33+
{
34+
id: "acme/prod",
35+
owner: "acme",
36+
name: "prod",
37+
fullName: "acme/prod",
38+
displayName: "prod",
39+
description: "Production worker",
40+
defaultBranch: "main",
41+
private: true,
42+
aliases: ["production"],
43+
keywords: ["worker", "slack"],
44+
},
45+
{
46+
id: "acme/web",
47+
owner: "acme",
48+
name: "web",
49+
fullName: "acme/web",
50+
displayName: "web",
51+
description: "Web application",
52+
defaultBranch: "main",
53+
private: true,
54+
aliases: ["frontend"],
55+
keywords: ["react", "ui"],
56+
},
57+
];
58+
59+
const TEST_ENV = {
60+
ANTHROPIC_API_KEY: "test-api-key",
61+
CLASSIFICATION_MODEL: "claude-haiku-4-5",
62+
} as Env;
63+
64+
describe("RepoClassifier", () => {
65+
beforeEach(() => {
66+
vi.clearAllMocks();
67+
mockGetAvailableRepos.mockResolvedValue(TEST_REPOS);
68+
mockGetReposByChannel.mockResolvedValue([]);
69+
mockBuildRepoDescriptions.mockResolvedValue("- acme/prod\n- acme/web");
70+
});
71+
72+
it("uses tool output when provider returns valid structured classification", async () => {
73+
mockMessagesCreate.mockResolvedValue({
74+
content: [
75+
{
76+
type: "tool_use",
77+
id: "toolu_1",
78+
name: "classify_repository",
79+
input: {
80+
repoId: "acme/prod",
81+
confidence: "high",
82+
reasoning: "The message explicitly mentions prod.",
83+
alternatives: [],
84+
},
85+
},
86+
],
87+
});
88+
89+
const classifier = new RepoClassifier(TEST_ENV);
90+
const result = await classifier.classify("please fix prod slack alerts", undefined, "trace-1");
91+
92+
expect(result.repo?.fullName).toBe("acme/prod");
93+
expect(result.confidence).toBe("high");
94+
expect(result.needsClarification).toBe(false);
95+
expect(mockMessagesCreate).toHaveBeenCalledWith(
96+
expect.objectContaining({
97+
temperature: 0,
98+
tool_choice: expect.objectContaining({
99+
type: "tool",
100+
name: "classify_repository",
101+
}),
102+
tools: [expect.objectContaining({ name: "classify_repository" })],
103+
})
104+
);
105+
});
106+
107+
it("asks for clarification when tool payload is invalid", async () => {
108+
mockMessagesCreate.mockResolvedValue({
109+
content: [
110+
{
111+
type: "tool_use",
112+
id: "toolu_2",
113+
name: "classify_repository",
114+
input: {
115+
repoId: "acme/prod",
116+
confidence: "certain",
117+
reasoning: "Totally sure",
118+
alternatives: [],
119+
},
120+
},
121+
],
122+
});
123+
124+
const classifier = new RepoClassifier(TEST_ENV);
125+
const result = await classifier.classify("please update prod deployment config");
126+
127+
expect(result.repo).toBeNull();
128+
expect(result.confidence).toBe("low");
129+
expect(result.needsClarification).toBe(true);
130+
expect(result.reasoning).toContain("structured model output");
131+
expect(result.alternatives).toHaveLength(2);
132+
});
133+
134+
it("asks for clarification when tool output is missing", async () => {
135+
mockMessagesCreate.mockResolvedValue({
136+
content: [
137+
{
138+
type: "text",
139+
text: '{"repoId":"acme/web","confidence":"high","reasoning":"Mentions frontend and UI.","alternatives":[]}',
140+
},
141+
],
142+
});
143+
144+
const classifier = new RepoClassifier(TEST_ENV);
145+
const result = await classifier.classify("frontend UI issue in web app");
146+
147+
expect(result.repo).toBeNull();
148+
expect(result.confidence).toBe("low");
149+
expect(result.needsClarification).toBe(true);
150+
expect(result.reasoning).toContain("structured model output");
151+
expect(result.alternatives).toHaveLength(2);
152+
});
153+
});

0 commit comments

Comments
 (0)