Skip to content

Commit fd2d349

Browse files
authored
Resolve SME chat Anthropic auth from persisted env (#379)
- Read SME Anthropic credentials from saved environment variables before sending - Avoid persisting placeholder messages when auth is missing - Add coverage for persisted credentials and auth failure handling
1 parent e330caf commit fd2d349

6 files changed

Lines changed: 807 additions & 370 deletions

File tree

apps/server/src/serverLayers.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ export function makeServerRuntimeServicesLayer() {
166166
const githubLayer = GitHubLive.pipe(Layer.provideMerge(GitHubCliLive));
167167

168168
const smeChatLayer = SmeChatServiceLive.pipe(
169+
Layer.provideMerge(EnvironmentVariablesLive),
169170
Layer.provide(SmeKnowledgeDocumentRepositoryLive),
170171
Layer.provide(SmeConversationRepositoryLive),
171172
Layer.provide(SmeMessageRepositoryLive),
Lines changed: 324 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,324 @@
1+
import { ProjectId, SmeConversationId, type EnvironmentVariableEntry } from "@okcode/contracts";
2+
import { Effect, Layer, Option } from "effect";
3+
import { afterEach, describe, expect, it, vi } from "vitest";
4+
5+
import {
6+
EnvironmentVariables,
7+
type EnvironmentVariablesShape,
8+
} from "../../persistence/Services/EnvironmentVariables.ts";
9+
import {
10+
SmeKnowledgeDocumentRepository,
11+
type SmeKnowledgeDocumentRepositoryShape,
12+
type SmeKnowledgeDocumentRow,
13+
} from "../../persistence/Services/SmeKnowledgeDocuments.ts";
14+
import {
15+
SmeConversationRepository,
16+
type SmeConversationRepositoryShape,
17+
type SmeConversationRow,
18+
} from "../../persistence/Services/SmeConversations.ts";
19+
import {
20+
SmeMessageRepository,
21+
type SmeMessageRepositoryShape,
22+
type SmeMessageRow,
23+
} from "../../persistence/Services/SmeMessages.ts";
24+
import { SmeChatService } from "../Services/SmeChatService.ts";
25+
import { makeSmeChatServiceLive } from "./SmeChatServiceLive.ts";
26+
27+
const originalAnthropicEnv = {
28+
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
29+
ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN,
30+
ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL,
31+
};
32+
33+
afterEach(() => {
34+
restoreAnthropicEnv();
35+
});
36+
37+
function restoreAnthropicEnv() {
38+
for (const [key, value] of Object.entries(originalAnthropicEnv)) {
39+
if (value === undefined) {
40+
delete process.env[key];
41+
} else {
42+
process.env[key] = value;
43+
}
44+
}
45+
}
46+
47+
function setAnthropicEnv(input: {
48+
readonly apiKey?: string;
49+
readonly authToken?: string;
50+
readonly baseURL?: string;
51+
}) {
52+
if (input.apiKey === undefined) {
53+
delete process.env.ANTHROPIC_API_KEY;
54+
} else {
55+
process.env.ANTHROPIC_API_KEY = input.apiKey;
56+
}
57+
58+
if (input.authToken === undefined) {
59+
delete process.env.ANTHROPIC_AUTH_TOKEN;
60+
} else {
61+
process.env.ANTHROPIC_AUTH_TOKEN = input.authToken;
62+
}
63+
64+
if (input.baseURL === undefined) {
65+
delete process.env.ANTHROPIC_BASE_URL;
66+
} else {
67+
process.env.ANTHROPIC_BASE_URL = input.baseURL;
68+
}
69+
}
70+
71+
function toEntries(record: Record<string, string>): EnvironmentVariableEntry[] {
72+
return Object.entries(record).map(([key, value]) => ({ key, value }));
73+
}
74+
75+
function makeEnvironmentVariables(persistedEnv: Record<string, string>): EnvironmentVariablesShape {
76+
const entries = toEntries(persistedEnv);
77+
78+
return {
79+
getGlobal: () => Effect.succeed({ entries }),
80+
saveGlobal: (input) => Effect.succeed({ entries: input.entries }),
81+
getProject: (input) => Effect.succeed({ projectId: input.projectId, entries }),
82+
saveProject: (input) =>
83+
Effect.succeed({
84+
projectId: input.projectId,
85+
entries: input.entries,
86+
}),
87+
resolveEnvironment: () => Effect.succeed(persistedEnv),
88+
};
89+
}
90+
91+
function makeDocumentRepository(
92+
rows: ReadonlyArray<SmeKnowledgeDocumentRow> = [],
93+
): SmeKnowledgeDocumentRepositoryShape {
94+
const documents = new Map(rows.map((row) => [row.documentId, row] as const));
95+
const getOption = <T>(value: T | undefined) =>
96+
value === undefined ? Option.none() : Option.some(value);
97+
98+
return {
99+
upsert: (row) =>
100+
Effect.sync(() => {
101+
documents.set(row.documentId, row);
102+
}),
103+
getById: ({ documentId }) => Effect.succeed(getOption(documents.get(documentId))),
104+
listByProjectId: ({ projectId }) =>
105+
Effect.succeed([...documents.values()].filter((row) => row.projectId === projectId)),
106+
deleteById: ({ documentId }) =>
107+
Effect.sync(() => {
108+
documents.delete(documentId);
109+
}),
110+
};
111+
}
112+
113+
function makeConversationRepository(
114+
rows: ReadonlyArray<SmeConversationRow>,
115+
): SmeConversationRepositoryShape {
116+
const conversations = new Map(rows.map((row) => [row.conversationId, row] as const));
117+
const getOption = <T>(value: T | undefined) =>
118+
value === undefined ? Option.none() : Option.some(value);
119+
120+
return {
121+
upsert: (row) =>
122+
Effect.sync(() => {
123+
conversations.set(row.conversationId, row);
124+
}),
125+
getById: ({ conversationId }) => Effect.succeed(getOption(conversations.get(conversationId))),
126+
listByProjectId: ({ projectId }) =>
127+
Effect.succeed([...conversations.values()].filter((row) => row.projectId === projectId)),
128+
deleteById: ({ conversationId }) =>
129+
Effect.sync(() => {
130+
conversations.delete(conversationId);
131+
}),
132+
};
133+
}
134+
135+
function makeMessageRepository() {
136+
const rowsByConversation = new Map<string, SmeMessageRow[]>();
137+
138+
const repository: SmeMessageRepositoryShape = {
139+
upsert: (row) =>
140+
Effect.sync(() => {
141+
const existing = rowsByConversation.get(row.conversationId) ?? [];
142+
const next = existing.filter((message) => message.messageId !== row.messageId);
143+
next.push(row);
144+
rowsByConversation.set(row.conversationId, next);
145+
}),
146+
listByConversationId: ({ conversationId }) =>
147+
Effect.succeed(rowsByConversation.get(conversationId) ?? []),
148+
deleteByConversationId: ({ conversationId }) =>
149+
Effect.sync(() => {
150+
rowsByConversation.delete(conversationId);
151+
}),
152+
};
153+
154+
return { repository, rowsByConversation };
155+
}
156+
157+
describe("SmeChatServiceLive", () => {
158+
it("uses persisted Anthropic credentials for a successful send and stores the final reply", async () => {
159+
setAnthropicEnv({
160+
apiKey: "process-key-that-should-not-win",
161+
authToken: "process-token-that-should-not-win",
162+
baseURL: "https://process-base.example",
163+
});
164+
165+
const projectId = ProjectId.makeUnsafe("project-1");
166+
const conversationId = SmeConversationId.makeUnsafe("conversation-1");
167+
const conversationRow: SmeConversationRow = {
168+
conversationId,
169+
projectId,
170+
title: "Architecture Q&A",
171+
model: "claude-sonnet-4-6",
172+
createdAt: "2026-01-01T00:00:00.000Z",
173+
updatedAt: "2026-01-01T00:00:00.000Z",
174+
deletedAt: null,
175+
};
176+
const persistedEnv = {
177+
ANTHROPIC_API_KEY: "project-api-key",
178+
ANTHROPIC_BASE_URL: "https://project-base.example",
179+
};
180+
const { repository: messageRepo, rowsByConversation } = makeMessageRepository();
181+
const capturedClientOptions: Array<unknown> = [];
182+
const capturedRequests: Array<unknown> = [];
183+
184+
const createClient = vi.fn((options: unknown) => {
185+
capturedClientOptions.push(options);
186+
return {
187+
messages: {
188+
stream: async function* (request: unknown) {
189+
capturedRequests.push(request);
190+
yield {
191+
type: "content_block_delta",
192+
delta: { type: "text_delta", text: "Hello" },
193+
};
194+
yield {
195+
type: "content_block_delta",
196+
delta: { type: "text_delta", text: " world" },
197+
};
198+
},
199+
},
200+
} as never;
201+
});
202+
203+
const layer = makeSmeChatServiceLive({ createClient }).pipe(
204+
Layer.provideMerge(
205+
Layer.succeed(EnvironmentVariables, makeEnvironmentVariables(persistedEnv)),
206+
),
207+
Layer.provideMerge(Layer.succeed(SmeKnowledgeDocumentRepository, makeDocumentRepository())),
208+
Layer.provideMerge(
209+
Layer.succeed(SmeConversationRepository, makeConversationRepository([conversationRow])),
210+
),
211+
Layer.provideMerge(Layer.succeed(SmeMessageRepository, messageRepo)),
212+
);
213+
214+
const events: Array<unknown> = [];
215+
await Effect.runPromise(
216+
Effect.gen(function* () {
217+
const service = yield* SmeChatService;
218+
yield* service.sendMessage(
219+
{
220+
conversationId,
221+
text: "What changed in the latest design?",
222+
},
223+
(event) => {
224+
events.push(event);
225+
},
226+
);
227+
}).pipe(Effect.provide(layer)),
228+
);
229+
230+
expect(createClient).toHaveBeenCalledTimes(1);
231+
expect(capturedClientOptions).toEqual([
232+
{
233+
apiKey: "project-api-key",
234+
authToken: null,
235+
baseURL: "https://project-base.example",
236+
},
237+
]);
238+
expect(capturedRequests).toEqual([
239+
{
240+
model: "claude-sonnet-4-6",
241+
max_tokens: 8192,
242+
system: expect.stringContaining("knowledgeable subject matter expert assistant"),
243+
messages: [{ role: "user", content: "What changed in the latest design?" }],
244+
},
245+
]);
246+
expect(events).toEqual([
247+
{
248+
type: "sme.message.delta",
249+
conversationId,
250+
messageId: expect.any(String),
251+
text: "Hello",
252+
},
253+
{
254+
type: "sme.message.delta",
255+
conversationId,
256+
messageId: expect.any(String),
257+
text: " world",
258+
},
259+
{
260+
type: "sme.message.complete",
261+
conversationId,
262+
messageId: expect.any(String),
263+
text: "Hello world",
264+
},
265+
]);
266+
267+
const storedMessages = rowsByConversation.get(conversationId);
268+
expect(storedMessages).toHaveLength(2);
269+
expect(
270+
storedMessages?.map((message) => ({
271+
role: message.role,
272+
text: message.text,
273+
isStreaming: message.isStreaming,
274+
})),
275+
).toEqual([
276+
{ role: "user", text: "What changed in the latest design?", isStreaming: false },
277+
{ role: "assistant", text: "Hello world", isStreaming: false },
278+
]);
279+
});
280+
281+
it("fails before persisting messages when no Anthropic credentials are available", async () => {
282+
setAnthropicEnv({});
283+
284+
const projectId = ProjectId.makeUnsafe("project-2");
285+
const conversationId = SmeConversationId.makeUnsafe("conversation-2");
286+
const conversationRow: SmeConversationRow = {
287+
conversationId,
288+
projectId,
289+
title: "Docs sync",
290+
model: "claude-sonnet-4-6",
291+
createdAt: "2026-01-01T00:00:00.000Z",
292+
updatedAt: "2026-01-01T00:00:00.000Z",
293+
deletedAt: null,
294+
};
295+
const { repository: messageRepo, rowsByConversation } = makeMessageRepository();
296+
const createClient = vi.fn();
297+
298+
const layer = makeSmeChatServiceLive({ createClient }).pipe(
299+
Layer.provideMerge(Layer.succeed(EnvironmentVariables, makeEnvironmentVariables({}))),
300+
Layer.provideMerge(Layer.succeed(SmeKnowledgeDocumentRepository, makeDocumentRepository())),
301+
Layer.provideMerge(
302+
Layer.succeed(SmeConversationRepository, makeConversationRepository([conversationRow])),
303+
),
304+
Layer.provideMerge(Layer.succeed(SmeMessageRepository, messageRepo)),
305+
);
306+
307+
await expect(
308+
Effect.runPromise(
309+
Effect.gen(function* () {
310+
const service = yield* SmeChatService;
311+
yield* service.sendMessage({
312+
conversationId,
313+
text: "Can you summarize the docs?",
314+
});
315+
}).pipe(Effect.provide(layer)),
316+
),
317+
).rejects.toThrow(
318+
"SmeChatError in sendMessage:auth: SME Chat requires ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN.",
319+
);
320+
321+
expect(createClient).not.toHaveBeenCalled();
322+
expect(rowsByConversation.get(conversationId) ?? []).toEqual([]);
323+
});
324+
});

0 commit comments

Comments
 (0)