From 9e4670c6547667120ebd8ad860844feb40c2f331 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Mon, 13 Apr 2026 00:00:47 -0500 Subject: [PATCH 1/3] Switch SME chat to direct Anthropic messaging - Replace provider-runtime routing with direct Anthropic client setup - Resolve Anthropic credentials from env or helper command and fail early when missing - Update SME chat tests to cover the new flow and error path --- .../src/sme/Layers/SmeChatServiceLive.test.ts | 366 +++++++----------- .../src/sme/Layers/SmeChatServiceLive.ts | 200 ++++++---- apps/server/src/sme/backends/anthropic.ts | 53 ++- 3 files changed, 309 insertions(+), 310 deletions(-) diff --git a/apps/server/src/sme/Layers/SmeChatServiceLive.test.ts b/apps/server/src/sme/Layers/SmeChatServiceLive.test.ts index 0551d0270..80048f7cc 100644 --- a/apps/server/src/sme/Layers/SmeChatServiceLive.test.ts +++ b/apps/server/src/sme/Layers/SmeChatServiceLive.test.ts @@ -1,8 +1,7 @@ import { ProjectId, SmeConversationId } from "@okcode/contracts"; -import { Effect, Layer, Option, Queue, Stream } from "effect"; +import { Effect, Layer, Option } from "effect"; import { describe, expect, it } from "vitest"; -import { OpenclawGatewayConfig } from "../../persistence/Services/OpenclawGatewayConfig.ts"; import { SmeKnowledgeDocumentRepository, type SmeKnowledgeDocumentRepositoryShape, @@ -18,14 +17,6 @@ import { type SmeMessageRepositoryShape, type SmeMessageRow, } from "../../persistence/Services/SmeMessages.ts"; -import { - ProviderHealth, - type ProviderHealthShape, -} from "../../provider/Services/ProviderHealth.ts"; -import { - ProviderService, - type ProviderServiceShape, -} from "../../provider/Services/ProviderService.ts"; import { SmeChatService } from "../Services/SmeChatService.ts"; import { makeSmeChatServiceLive } from "./SmeChatServiceLive.ts"; @@ -95,132 +86,8 @@ function makeMessageRepository() { return { repository, rowsByConversation }; } -function makeProviderHealth( - statuses: Array<{ - readonly provider: "codex" | "claudeAgent" | "openclaw"; - readonly status: "ready" | "warning" | "error"; - readonly available: boolean; - readonly authStatus: "authenticated" | "unauthenticated" | "unknown"; - readonly checkedAt: string; - readonly message?: string; - }>, -): ProviderHealthShape { - return { - getStatuses: Effect.succeed(statuses), - }; -} - -function makeProviderService() { - const runtimeEvents = Effect.runSync(Queue.unbounded()); - const startedSessions: Array = []; - const sentTurns: Array = []; - - const service: ProviderServiceShape = { - startSession: (threadId, input) => - Effect.sync(() => { - startedSessions.push({ threadId, input }); - return { - provider: input.provider ?? "claudeAgent", - status: "ready", - runtimeMode: input.runtimeMode, - threadId, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - } as never; - }), - sendTurn: (input) => - Effect.gen(function* () { - sentTurns.push(input); - const turnId = "turn-1" as never; - yield* Queue.offer(runtimeEvents, { - eventId: "evt-1" as never, - provider: "claudeAgent", - threadId: input.threadId, - turnId, - createdAt: "2026-01-01T00:00:00.000Z", - type: "content.delta", - payload: { - streamKind: "assistant_text", - delta: "Hello", - }, - } as never); - yield* Queue.offer(runtimeEvents, { - eventId: "evt-2" as never, - provider: "claudeAgent", - threadId: input.threadId, - turnId, - createdAt: "2026-01-01T00:00:00.000Z", - type: "content.delta", - payload: { - streamKind: "assistant_text", - delta: " world", - }, - } as never); - yield* Queue.offer(runtimeEvents, { - eventId: "evt-3" as never, - provider: "claudeAgent", - threadId: input.threadId, - turnId, - createdAt: "2026-01-01T00:00:00.000Z", - type: "turn.completed", - payload: { - state: "completed", - }, - } as never); - return { - threadId: input.threadId, - turnId, - } as never; - }), - interruptTurn: () => Effect.void, - respondToRequest: () => Effect.void, - respondToUserInput: () => Effect.void, - stopSession: () => Effect.void, - listSessions: () => Effect.succeed([]), - getCapabilities: () => Effect.die("unexpected provider getCapabilities"), - rollbackConversation: () => Effect.void, - streamEvents: Stream.fromQueue(runtimeEvents), - }; - - return { service, startedSessions, sentTurns }; -} - -function makeOpenclawGatewayConfig() { - return { - getSummary: () => - Effect.succeed({ - gatewayUrl: null, - hasSharedSecret: false, - deviceId: null, - devicePublicKey: null, - deviceFingerprint: null, - hasDeviceToken: false, - deviceTokenRole: null, - deviceTokenScopes: [], - updatedAt: null, - }), - getStored: () => Effect.succeed(null), - save: () => Effect.die("unexpected openclaw save"), - resolveForConnect: () => Effect.succeed(null), - saveDeviceToken: () => Effect.void, - clearDeviceToken: () => Effect.void, - resetDeviceState: () => - Effect.succeed({ - gatewayUrl: null, - hasSharedSecret: false, - deviceId: null, - devicePublicKey: null, - deviceFingerprint: null, - hasDeviceToken: false, - deviceTokenRole: null, - deviceTokenScopes: [], - updatedAt: null, - }), - }; -} - describe("SmeChatServiceLive", () => { - it("routes Claude conversations through the provider runtime and stores the reply", async () => { + it("routes Claude conversations through direct Anthropic chat and stores the reply", async () => { const projectId = ProjectId.makeUnsafe("project-1"); const conversationId = SmeConversationId.makeUnsafe("conversation-1"); const conversationRow: SmeConversationRow = { @@ -228,78 +95,110 @@ describe("SmeChatServiceLive", () => { projectId, title: "Architecture Q&A", provider: "claudeAgent", - authMethod: "auto", + authMethod: "apiKey", model: "claude-sonnet-4-6", createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", deletedAt: null, }; const { repository: messageRepo, rowsByConversation } = makeMessageRepository(); - const providerService = makeProviderService(); + const sendInputs: Array = []; + const sendClaudeMessage = (input: any) => + Effect.sync(() => { + sendInputs.push(input); + input.onEvent?.({ + type: "sme.message.delta", + conversationId: input.conversationId, + messageId: input.assistantMessageId, + text: "Hello", + }); + input.onEvent?.({ + type: "sme.message.delta", + conversationId: input.conversationId, + messageId: input.assistantMessageId, + text: " world", + }); + return "Hello world"; + }); - const layer = makeSmeChatServiceLive().pipe( - Layer.provideMerge( - Layer.succeed( - ProviderHealth, - makeProviderHealth([ - { - provider: "claudeAgent", - status: "ready", - available: true, - authStatus: "authenticated", - checkedAt: "2026-01-01T00:00:00.000Z", - message: "Claude Code CLI is ready.", - }, - ]), - ), - ), + const layer = makeSmeChatServiceLive({ sendSmeViaAnthropic: sendClaudeMessage }).pipe( Layer.provideMerge(Layer.succeed(SmeKnowledgeDocumentRepository, makeDocumentRepository())), - Layer.provideMerge( - Layer.succeed(SmeConversationRepository, makeConversationRepository([conversationRow])), - ), + Layer.provideMerge(Layer.succeed(SmeConversationRepository, makeConversationRepository([conversationRow]))), Layer.provideMerge(Layer.succeed(SmeMessageRepository, messageRepo)), - Layer.provideMerge(Layer.succeed(OpenclawGatewayConfig, makeOpenclawGatewayConfig())), - Layer.provideMerge(Layer.succeed(ProviderService, providerService.service)), ); + const savedEnv = { + ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY, + ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN, + ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL, + ANTHROPIC_API_BASE_URL: process.env.ANTHROPIC_API_BASE_URL, + }; + delete process.env.ANTHROPIC_API_KEY; + delete process.env.ANTHROPIC_AUTH_TOKEN; + delete process.env.ANTHROPIC_BASE_URL; + delete process.env.ANTHROPIC_API_BASE_URL; + const events: Array = []; - await Effect.runPromise( - Effect.gen(function* () { - const service = yield* SmeChatService; - yield* service.sendMessage( - { - conversationId, - text: "What changed in the latest design?", - providerOptions: { - claudeAgent: { - binaryPath: "/usr/local/bin/claude", - permissionMode: "plan", - maxThinkingTokens: 12_000, + try { + await Effect.runPromise( + Effect.gen(function* () { + const service = yield* SmeChatService; + yield* service.sendMessage( + { + conversationId, + text: "What changed in the latest design?", + providerOptions: { + claudeAgent: { + authTokenHelperCommand: "printf test-token", + }, }, }, - }, - (event) => { - events.push(event); - }, - ); - }).pipe(Effect.provide(layer)), - ); + (event) => { + events.push(event); + }, + ); + }).pipe(Effect.provide(layer)), + ); + } finally { + if (savedEnv.ANTHROPIC_API_KEY === undefined) { + delete process.env.ANTHROPIC_API_KEY; + } else { + process.env.ANTHROPIC_API_KEY = savedEnv.ANTHROPIC_API_KEY; + } + if (savedEnv.ANTHROPIC_AUTH_TOKEN === undefined) { + delete process.env.ANTHROPIC_AUTH_TOKEN; + } else { + process.env.ANTHROPIC_AUTH_TOKEN = savedEnv.ANTHROPIC_AUTH_TOKEN; + } + if (savedEnv.ANTHROPIC_BASE_URL === undefined) { + delete process.env.ANTHROPIC_BASE_URL; + } else { + process.env.ANTHROPIC_BASE_URL = savedEnv.ANTHROPIC_BASE_URL; + } + if (savedEnv.ANTHROPIC_API_BASE_URL === undefined) { + delete process.env.ANTHROPIC_API_BASE_URL; + } else { + process.env.ANTHROPIC_API_BASE_URL = savedEnv.ANTHROPIC_API_BASE_URL; + } + } - expect(providerService.startedSessions).toHaveLength(1); - expect(providerService.sentTurns).toHaveLength(1); - expect((providerService.startedSessions[0] as any).input.providerOptions).toEqual({ - claudeAgent: { - binaryPath: "/usr/local/bin/claude", - permissionMode: "plan", - maxThinkingTokens: 12_000, - }, - }); - expect(providerService.sentTurns[0] as any).toEqual( + expect(sendInputs).toHaveLength(1); + expect(sendInputs[0]).toEqual( expect.objectContaining({ + clientOptions: expect.objectContaining({ + authToken: "test-token", + apiKey: null, + }), model: "claude-sonnet-4-6", - input: expect.stringContaining("knowledgeable subject matter expert assistant"), + systemPrompt: expect.stringContaining("plain assistant text only"), + messages: [{ role: "user", content: "What changed in the latest design?" }], }), ); + expect(sendInputs[0].messages).toHaveLength(1); + expect(sendInputs[0].messages[0]).toEqual({ + role: "user", + content: "What changed in the latest design?", + }); expect(events).toEqual([ { type: "sme.message.delta", @@ -335,7 +234,7 @@ describe("SmeChatServiceLive", () => { ]); }); - it("fails before sending when Claude Code CLI is unavailable", async () => { + it("fails before sending when Claude credentials are unavailable", async () => { const projectId = ProjectId.makeUnsafe("project-2"); const conversationId = SmeConversationId.makeUnsafe("conversation-2"); const conversationRow: SmeConversationRow = { @@ -343,56 +242,67 @@ describe("SmeChatServiceLive", () => { projectId, title: "Docs sync", provider: "claudeAgent", - authMethod: "auto", + authMethod: "apiKey", model: "claude-sonnet-4-6", createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", deletedAt: null, }; const { repository: messageRepo, rowsByConversation } = makeMessageRepository(); - const providerService = makeProviderService(); - - const layer = makeSmeChatServiceLive().pipe( - Layer.provideMerge( - Layer.succeed( - ProviderHealth, - makeProviderHealth([ - { - provider: "claudeAgent", - status: "error", - available: false, - authStatus: "unknown", - checkedAt: "2026-01-01T00:00:00.000Z", - message: "Claude Code CLI (`claude`) is not installed or not on PATH.", - }, - ]), - ), - ), + const layer = makeSmeChatServiceLive({ sendSmeViaAnthropic: () => Effect.die("unexpected send") }).pipe( Layer.provideMerge(Layer.succeed(SmeKnowledgeDocumentRepository, makeDocumentRepository())), - Layer.provideMerge( - Layer.succeed(SmeConversationRepository, makeConversationRepository([conversationRow])), - ), + Layer.provideMerge(Layer.succeed(SmeConversationRepository, makeConversationRepository([conversationRow]))), Layer.provideMerge(Layer.succeed(SmeMessageRepository, messageRepo)), - Layer.provideMerge(Layer.succeed(OpenclawGatewayConfig, makeOpenclawGatewayConfig())), - Layer.provideMerge(Layer.succeed(ProviderService, providerService.service)), ); - await expect( - Effect.runPromise( - Effect.gen(function* () { - const service = yield* SmeChatService; - yield* service.sendMessage({ - conversationId, - text: "Can you summarize the docs?", - }); - }).pipe(Effect.provide(layer)), - ), - ).rejects.toThrow( - "SmeChatError in sendMessage:validate: Claude Code CLI (`claude`) is not installed or not on PATH.", - ); + const savedEnv = { + ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY, + ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN, + ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL, + ANTHROPIC_API_BASE_URL: process.env.ANTHROPIC_API_BASE_URL, + }; + delete process.env.ANTHROPIC_API_KEY; + delete process.env.ANTHROPIC_AUTH_TOKEN; + delete process.env.ANTHROPIC_BASE_URL; + delete process.env.ANTHROPIC_API_BASE_URL; + + try { + await expect( + Effect.runPromise( + Effect.gen(function* () { + const service = yield* SmeChatService; + yield* service.sendMessage({ + conversationId, + text: "Can you summarize the docs?", + }); + }).pipe(Effect.provide(layer)), + ), + ).rejects.toThrow( + "SmeChatError in sendMessage:validate: Claude SME Chat needs an Anthropic API key, auth token, or auth token helper command.", + ); + } finally { + if (savedEnv.ANTHROPIC_API_KEY === undefined) { + delete process.env.ANTHROPIC_API_KEY; + } else { + process.env.ANTHROPIC_API_KEY = savedEnv.ANTHROPIC_API_KEY; + } + if (savedEnv.ANTHROPIC_AUTH_TOKEN === undefined) { + delete process.env.ANTHROPIC_AUTH_TOKEN; + } else { + process.env.ANTHROPIC_AUTH_TOKEN = savedEnv.ANTHROPIC_AUTH_TOKEN; + } + if (savedEnv.ANTHROPIC_BASE_URL === undefined) { + delete process.env.ANTHROPIC_BASE_URL; + } else { + process.env.ANTHROPIC_BASE_URL = savedEnv.ANTHROPIC_BASE_URL; + } + if (savedEnv.ANTHROPIC_API_BASE_URL === undefined) { + delete process.env.ANTHROPIC_API_BASE_URL; + } else { + process.env.ANTHROPIC_API_BASE_URL = savedEnv.ANTHROPIC_API_BASE_URL; + } + } - expect(providerService.startedSessions).toHaveLength(0); - expect(providerService.sentTurns).toHaveLength(0); expect(rowsByConversation.get(conversationId)).toEqual([ expect.objectContaining({ role: "user", diff --git a/apps/server/src/sme/Layers/SmeChatServiceLive.ts b/apps/server/src/sme/Layers/SmeChatServiceLive.ts index d7058e3d5..c1f53c569 100644 --- a/apps/server/src/sme/Layers/SmeChatServiceLive.ts +++ b/apps/server/src/sme/Layers/SmeChatServiceLive.ts @@ -7,6 +7,7 @@ * @module SmeChatServiceLive */ import type { + ProviderStartOptions, SmeAuthMethod, SmeConversation, SmeKnowledgeDocument, @@ -20,30 +21,40 @@ import { import { DateTime, Effect, Layer, Option, Random, Ref } from "effect"; import crypto from "node:crypto"; -import { OpenclawGatewayConfig } from "../../persistence/Services/OpenclawGatewayConfig.ts"; import { SmeConversationRepository } from "../../persistence/Services/SmeConversations.ts"; import { SmeKnowledgeDocumentRepository } from "../../persistence/Services/SmeKnowledgeDocuments.ts"; import { SmeMessageRepository } from "../../persistence/Services/SmeMessages.ts"; -import { ProviderService } from "../../provider/Services/ProviderService.ts"; -import { ProviderHealth } from "../../provider/Services/ProviderHealth.ts"; +import { isValidSmeAuthMethod } from "../authValidation.ts"; import { - isValidSmeAuthMethod, - validateClaudeSetup, - validateCodexSetup, - validateOpenClawSetup, -} from "../authValidation.ts"; -import { sendSmeViaProviderRuntime } from "../backends/providerRuntime.ts"; -import { buildSmeCompiledPrompt } from "../promptBuilder.ts"; + resolveAnthropicClientOptions, + sendSmeViaAnthropic, +} from "../backends/anthropic.ts"; +import { buildSmeSystemPrompt } from "../promptBuilder.ts"; import { SmeChatError, SmeChatService, type SmeChatServiceShape, } from "../Services/SmeChatService.ts"; +import type { MessageParam } from "@anthropic-ai/sdk/resources"; type ActiveRequest = { readonly interrupt: Effect.Effect; }; +interface SmeChatServiceLiveOptions { + readonly sendSmeViaAnthropic?: typeof sendSmeViaAnthropic; +} + +const SME_CHAT_SUPPORTED_PROVIDER: SmeConversation["provider"] = "claudeAgent"; + +function isSmeChatProviderSupported(provider: SmeConversation["provider"]) { + return provider === SME_CHAT_SUPPORTED_PROVIDER; +} + +function unsupportedProviderMessage(provider: SmeConversation["provider"]) { + return `SME Chat only supports Claude Code conversations right now. '${provider}' can still request tools or interactive approvals, which SME Chat does not implement.`; +} + function ensureValidConversationAuth( provider: SmeConversation["provider"], authMethod: SmeAuthMethod, @@ -103,14 +114,12 @@ function toMessage(message: { }; } -const makeSmeChatService = () => +const makeSmeChatService = (options?: SmeChatServiceLiveOptions) => Effect.gen(function* () { const documentRepo = yield* SmeKnowledgeDocumentRepository; const conversationRepo = yield* SmeConversationRepository; const messageRepo = yield* SmeMessageRepository; - const openclawGatewayConfig = yield* OpenclawGatewayConfig; - const providerService = yield* ProviderService; - const providerHealth = yield* ProviderHealth; + const sendClaudeMessage = options?.sendSmeViaAnthropic ?? sendSmeViaAnthropic; const activeRequests = yield* Ref.make(new Map()); @@ -146,61 +155,45 @@ const makeSmeChatService = () => conversation.authMethod, "validateSetup", ); + if (!isSmeChatProviderSupported(conversation.provider)) { + return { + ok: false, + severity: "error" as const, + message: unsupportedProviderMessage(conversation.provider), + resolvedAuthMethod: conversation.authMethod, + resolvedAccountType: "unknown" as const, + }; + } + + const clientOptions = yield* Effect.try({ + try: () => + resolveAnthropicClientOptions({ + providerOptions: providerOptions?.claudeAgent, + }), + catch: (cause) => new SmeChatError("validateSetup", String(cause), cause), + }); - switch (conversation.provider) { - case "claudeAgent": { - const providerStatus = (yield* providerHealth.getStatuses).find( - (status) => status.provider === "claudeAgent", - ); - return validateClaudeSetup({ - authMethod: conversation.authMethod as Extract< - SmeAuthMethod, - "auto" | "apiKey" | "authToken" - >, - providerStatus, - }); - } - - case "codex": - return yield* Effect.tryPromise({ - try: () => - validateCodexSetup({ - authMethod: conversation.authMethod as Extract< - SmeAuthMethod, - "auto" | "apiKey" | "chatgpt" | "customProvider" - >, - providerOptions, - }), - catch: (cause) => - new SmeChatError("validateSetup", "Failed to validate Codex setup.", cause), - }); - - case "copilot": - return { - ok: false, - severity: "warning" as const, - message: "GitHub Copilot is not available in SME Chat yet.", - resolvedAuthMethod: "auto" as const, - }; - - case "openclaw": - const openclawSummary = yield* openclawGatewayConfig - .getSummary() - .pipe(Effect.mapError((e) => new SmeChatError("validateSetup", e.message))); - const openclawStatus = (yield* providerHealth.getStatuses).find( - (status) => status.provider === "openclaw", - ); - return validateOpenClawSetup({ - authMethod: conversation.authMethod as Extract< - SmeAuthMethod, - "auto" | "password" | "none" - >, - gatewayUrl: openclawSummary.gatewayUrl, - hasSharedSecret: openclawSummary.hasSharedSecret, - hasDeviceToken: openclawSummary.hasDeviceToken, - ...(openclawStatus ? { providerStatus: openclawStatus } : {}), - }); + if (!clientOptions.apiKey && !clientOptions.authToken) { + return { + ok: false, + severity: "error" as const, + message: + "Claude SME Chat needs an Anthropic API key, auth token, or auth token helper command. Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN, or configure `authTokenHelperCommand` in Settings.", + resolvedAuthMethod: conversation.authMethod, + resolvedAccountType: "unknown" as const, + }; } + + return { + ok: true, + severity: "ready" as const, + message: + clientOptions.apiKey !== null + ? "Claude SME Chat can use the configured Anthropic API key." + : "Claude SME Chat can use the configured Anthropic auth token.", + resolvedAuthMethod: conversation.authMethod, + resolvedAccountType: clientOptions.apiKey !== null ? "apiKey" : "unknown", + }; }); const uploadDocument: SmeChatServiceShape["uploadDocument"] = (input) => @@ -289,6 +282,11 @@ const makeSmeChatService = () => const createConversation: SmeChatServiceShape["createConversation"] = (input) => Effect.gen(function* () { yield* ensureValidConversationAuth(input.provider, input.authMethod, "createConversation"); + if (!isSmeChatProviderSupported(input.provider)) { + return yield* Effect.fail( + new SmeChatError("createConversation", unsupportedProviderMessage(input.provider)), + ); + } const existing = yield* conversationRepo .listByProjectId({ projectId: input.projectId }) @@ -326,6 +324,11 @@ const makeSmeChatService = () => const updateConversation: SmeChatServiceShape["updateConversation"] = (input) => Effect.gen(function* () { yield* ensureValidConversationAuth(input.provider, input.authMethod, "updateConversation"); + if (!isSmeChatProviderSupported(input.provider)) { + return yield* Effect.fail( + new SmeChatError("updateConversation", unsupportedProviderMessage(input.provider)), + ); + } const existing = yield* conversationRepo .getById({ conversationId: input.conversationId }) .pipe(Effect.mapError((e) => new SmeChatError("updateConversation", e.message))); @@ -410,6 +413,12 @@ const makeSmeChatService = () => } const conv = conversation.value; + if (!isSmeChatProviderSupported(conv.provider)) { + return yield* Effect.fail( + new SmeChatError("sendMessage", unsupportedProviderMessage(conv.provider)), + ); + } + const docs = yield* documentRepo .listByProjectId({ projectId: conv.projectId }) .pipe(Effect.mapError((e) => new SmeChatError("sendMessage", e.message))); @@ -448,25 +457,57 @@ const makeSmeChatService = () => role: message.role, text: message.text, })); - const compiledPrompt = buildSmeCompiledPrompt({ - docs, - history: promptHistory, - userText: input.text, + const anthropicClientOptions = yield* Effect.try({ + try: () => + resolveAnthropicClientOptions({ + providerOptions: input.providerOptions?.claudeAgent, + }), + catch: (cause) => + new SmeChatError("sendMessage:providerRuntime", String(cause), cause), }); - const sendEffect = sendSmeViaProviderRuntime({ - providerService, - provider: conv.provider, + if (!anthropicClientOptions.apiKey && !anthropicClientOptions.authToken) { + return yield* Effect.fail( + new SmeChatError( + "sendMessage:providerRuntime", + "Claude SME Chat needs an Anthropic API key, auth token, or auth token helper command. Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN, or configure `authTokenHelperCommand` in Settings.", + ), + ); + } + + const systemPrompt = buildSmeSystemPrompt(docs); + const messages: ReadonlyArray = [ + ...promptHistory + .filter((message) => message.role === "user" || message.role === "assistant") + .map((message) => ({ + role: message.role, + content: message.text, + })) as Array, + { + role: "user", + content: input.text, + }, + ]; + + const abortController = new AbortController(); + + const sendEffect = sendClaudeMessage({ + clientOptions: anthropicClientOptions, conversationId: input.conversationId, assistantMessageId, model: conv.model, - compiledPrompt, - ...(input.providerOptions ? { providerOptions: input.providerOptions } : {}), + systemPrompt, + messages, ...(onEvent ? { onEvent } : {}), - setInterruptEffect: (interrupt) => setInterrupt(input.conversationId, interrupt), - clearInterruptEffect: clearInterrupt(input.conversationId), + abortSignal: abortController.signal, }); + yield* input.setInterruptEffect( + Effect.sync(() => { + abortController.abort(); + }), + ); + const responseText = yield* sendEffect.pipe( Effect.mapError((cause) => cause instanceof SmeChatError @@ -532,6 +573,7 @@ const makeSmeChatService = () => } satisfies SmeChatServiceShape; }); -export const makeSmeChatServiceLive = () => Layer.effect(SmeChatService, makeSmeChatService()); +export const makeSmeChatServiceLive = (options?: SmeChatServiceLiveOptions) => + Layer.effect(SmeChatService, makeSmeChatService(options)); export const SmeChatServiceLive = makeSmeChatServiceLive(); diff --git a/apps/server/src/sme/backends/anthropic.ts b/apps/server/src/sme/backends/anthropic.ts index 419e851f4..6815eeb6c 100644 --- a/apps/server/src/sme/backends/anthropic.ts +++ b/apps/server/src/sme/backends/anthropic.ts @@ -1,8 +1,11 @@ import Anthropic from "@anthropic-ai/sdk"; +import type { MessageParam } from "@anthropic-ai/sdk/resources"; import type { SmeMessageEvent } from "@okcode/contracts"; import { Effect } from "effect"; +import { readClaudeAuthTokenFromHelperCommand } from "../../provider/claudeAuthTokenHelper.ts"; import { SmeChatError } from "../Services/SmeChatService.ts"; +import type { ProviderStartOptions } from "@okcode/contracts"; type AnthropicMessagesClient = Pick; @@ -12,13 +15,55 @@ export interface ResolvedAnthropicClientOptions { readonly baseURL?: string; } +export interface ResolveAnthropicClientOptionsInput { + readonly providerOptions?: ProviderStartOptions["claudeAgent"]; + readonly env?: NodeJS.ProcessEnv; +} + +function nonEmptyTrimmed(value: string | undefined): string | undefined { + if (!value) return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +export function resolveAnthropicClientOptions( + input?: ResolveAnthropicClientOptionsInput, +): ResolvedAnthropicClientOptions { + const env = input?.env ?? process.env; + const explicitApiKey = nonEmptyTrimmed(env.ANTHROPIC_API_KEY); + const explicitAuthToken = nonEmptyTrimmed(env.ANTHROPIC_AUTH_TOKEN); + const helperCommand = nonEmptyTrimmed(input?.providerOptions?.authTokenHelperCommand); + + let authToken = explicitAuthToken; + if (!authToken && helperCommand) { + authToken = readClaudeAuthTokenFromHelperCommand(helperCommand, { env }); + } + + const baseURL = nonEmptyTrimmed(env.ANTHROPIC_BASE_URL ?? env.ANTHROPIC_API_BASE_URL); + + return { + apiKey: authToken ? null : explicitApiKey ?? null, + authToken: authToken ?? null, + ...(baseURL ? { baseURL } : {}), + }; +} + +function createAnthropicClient(options: ResolvedAnthropicClientOptions): AnthropicMessagesClient { + return new Anthropic({ + ...(options.apiKey ? { apiKey: options.apiKey } : {}), + ...(options.authToken ? { authToken: options.authToken } : {}), + ...(options.baseURL ? { baseURL: options.baseURL } : {}), + }); +} + export interface SendSmeViaAnthropicInput { - readonly client: AnthropicMessagesClient; + readonly client?: AnthropicMessagesClient; + readonly messages: ReadonlyArray; readonly conversationId: string; readonly assistantMessageId: string; readonly model: string; readonly systemPrompt: string; - readonly messages: Array<{ role: "user" | "assistant"; content: string }>; + readonly clientOptions?: ResolvedAnthropicClientOptions; readonly onEvent?: ((event: SmeMessageEvent) => void) | undefined; readonly abortSignal?: AbortSignal | undefined; } @@ -27,7 +72,9 @@ export function sendSmeViaAnthropic(input: SendSmeViaAnthropicInput) { return Effect.tryPromise({ try: async () => { let result = ""; - const stream = input.client.messages.stream( + const client = + input.client ?? createAnthropicClient(input.clientOptions ?? resolveAnthropicClientOptions()); + const stream = client.messages.stream( { model: input.model, max_tokens: 8192, From 91ad734759c773b541dade43a5400ce8740c690a Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Mon, 13 Apr 2026 00:04:50 -0500 Subject: [PATCH 2/3] Show recent PR review activity in the dashboard (#431) - Add recent maintainer reviews and decision summary to PR review - Surface PR Review in navigation and register Copilot settings --- apps/server/src/openclaw/GatewayClient.ts | 2 +- apps/server/src/prReview/Layers/PrReview.ts | 39 +++++++++ apps/web/src/appSettings.test.ts | 2 + apps/web/src/components/CommandPalette.tsx | 12 +++ apps/web/src/components/Sidebar.tsx | 5 +- .../src/components/chat/ProviderSetupCard.tsx | 1 + .../components/pr-review/PrReviewShell.tsx | 84 ++++++++++++++++++- apps/web/src/routes/_chat.settings.tsx | 11 +++ packages/contracts/src/prReview.ts | 8 ++ 9 files changed, 158 insertions(+), 6 deletions(-) diff --git a/apps/server/src/openclaw/GatewayClient.ts b/apps/server/src/openclaw/GatewayClient.ts index 734bbee23..3bff51c5f 100644 --- a/apps/server/src/openclaw/GatewayClient.ts +++ b/apps/server/src/openclaw/GatewayClient.ts @@ -466,7 +466,7 @@ export class OpenclawGatewayClient { if (frame.type === "event" && typeof frame.event === "string") { let matchedWaiter = false; - for (const waiter of [...this.pendingEventWaiters]) { + for (const waiter of this.pendingEventWaiters) { if (waiter.eventName === frame.event) { matchedWaiter = true; this.pendingEventWaiters.delete(waiter); diff --git a/apps/server/src/prReview/Layers/PrReview.ts b/apps/server/src/prReview/Layers/PrReview.ts index 454109ce8..12c0d052f 100644 --- a/apps/server/src/prReview/Layers/PrReview.ts +++ b/apps/server/src/prReview/Layers/PrReview.ts @@ -61,6 +61,17 @@ query PullRequestReviewDashboard($owner: String!, $name: String!, $number: Int!) headRefName baseRefOid headRefOid + reviews(last: 100) { + nodes { + state + body + submittedAt + authorAssociation + author { + login + } + } + } labels(first: 20) { nodes { name color } } @@ -300,6 +311,32 @@ function normalizeStatusChecks(raw: unknown): PrReviewSummary["statusChecks"] { return statusChecks; } +function normalizeRecentReviews(raw: unknown): PrReviewSummary["recentReviews"] { + if (!Array.isArray(raw)) return []; + const maintainerAssociations = new Set(["COLLABORATOR", "MEMBER", "OWNER"]); + return raw + .map((entry) => { + if (!entry || typeof entry !== "object") return null; + const record = entry as Record; + if (!maintainerAssociations.has(asString(record.authorAssociation) ?? "")) return null; + const submittedAt = asString(record.submittedAt); + const state = asString(record.state); + const author = + record.author && typeof record.author === "object" + ? asString((record.author as Record).login) + : null; + if (!submittedAt || !state || !author) return null; + return { + authorLogin: author, + state, + body: typeof record.body === "string" ? record.body : "", + submittedAt, + } satisfies PrReviewSummary["recentReviews"][number]; + }) + .filter((entry): entry is PrReviewSummary["recentReviews"][number] => entry !== null) + .toSorted((a, b) => Date.parse(b.submittedAt) - Date.parse(a.submittedAt)); +} + function normalizeDashboardResponse( raw: unknown, ): Pick { @@ -343,6 +380,7 @@ function normalizeDashboardResponse( ((pullRequest.commits as any)?.nodes?.[0] as any)?.commit?.statusCheckRollup?.contexts?.nodes ?? [], ); + const recentReviews = normalizeRecentReviews((pullRequest.reviews as any)?.nodes ?? []); const threads = Array.isArray((pullRequest.reviewThreads as any)?.nodes) ? ((pullRequest.reviewThreads as any).nodes as unknown[]) @@ -394,6 +432,7 @@ function normalizeDashboardResponse( .map((entry) => normalizeUser(entry)) .filter((entry): entry is GitHubUserPreview => entry !== null) .map((user) => ({ user, role: "requestedReviewer" as const })), + recentReviews, totalThreadCount: threads.length, unresolvedThreadCount: threads.filter((thread) => !thread.isResolved).length, headSha: asString(pullRequest.headRefOid), diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index d6fbb4ca3..41c453d1f 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -71,6 +71,8 @@ describe("getProviderStartOptions", () => { claudeAuthTokenHelperCommand: "op read op://shared/anthropic/token --no-newline", codexBinaryPath: "", codexHomePath: "", + copilotBinaryPath: "", + copilotConfigDir: "", openclawGatewayUrl: "", openclawPassword: "", }), diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 1960c2c11..87403fa34 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -13,6 +13,7 @@ import { SunIcon, GitBranchIcon, GitMergeIcon, + GitPullRequestIcon, SearchIcon, KeyboardIcon, } from "lucide-react"; @@ -222,6 +223,17 @@ function CommandsView() { void navigate({ to: "/skills", search: { create: undefined, name: undefined } }); }, }); + cmds.push({ + id: "nav-pr-review", + label: "Open PR Review", + keywords: ["pr review", "pull request", "review", "github", "maintainer"], + icon: GitPullRequestIcon, + group: "Navigation", + onSelect: () => { + closePalette(); + void navigate({ to: "/pr-review" }); + }, + }); // ── Project quick-switch (inline, first 5) ── for (const project of projects.slice(0, 5)) { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index a28cd2f35..d944ba8bf 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -33,7 +33,6 @@ import { ChevronsDownUpIcon, ChevronsUpDownIcon, CircleDotIcon, - ExternalLinkIcon, FolderIcon, GitBranchIcon, GitMergeIcon, @@ -2290,8 +2289,8 @@ export default function Sidebar() { className="gap-2 px-2 py-1.5 text-muted-foreground/70 hover:bg-accent hover:text-foreground" onClick={() => void navigate({ to: "/pr-review" })} > - - Open Workspace + + PR Review diff --git a/apps/web/src/components/chat/ProviderSetupCard.tsx b/apps/web/src/components/chat/ProviderSetupCard.tsx index 20eb2b586..811a0fa1e 100644 --- a/apps/web/src/components/chat/ProviderSetupCard.tsx +++ b/apps/web/src/components/chat/ProviderSetupCard.tsx @@ -35,6 +35,7 @@ const PROVIDER_CONFIG = { installCmd: "npm install -g @github/copilot", authCmd: "copilot login", verifyCmd: "gh auth status", + note: undefined, }, } as const; diff --git a/apps/web/src/components/pr-review/PrReviewShell.tsx b/apps/web/src/components/pr-review/PrReviewShell.tsx index d115c668a..4ade2b2c2 100644 --- a/apps/web/src/components/pr-review/PrReviewShell.tsx +++ b/apps/web/src/components/pr-review/PrReviewShell.tsx @@ -61,6 +61,34 @@ function resolvePrReviewConfigPath(projectCwd: string, configPath: string): stri return joinPath(projectCwd, configPath); } +function formatReviewDecision(decision: string | null | undefined): string { + if (!decision) return "No decision"; + return decision.toLowerCase().replaceAll("_", " "); +} + +function reviewDecisionTone(decision: string | null | undefined): string { + switch (decision) { + case "APPROVED": + return "text-emerald-600 dark:text-emerald-400"; + case "CHANGES_REQUESTED": + case "REVIEW_REQUIRED": + return "text-amber-600 dark:text-amber-400"; + default: + return "text-muted-foreground"; + } +} + +function formatReviewTimestamp(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }).format(date); +} + export function PrReviewShell({ project, projects, @@ -360,6 +388,8 @@ export function PrReviewShell({ const blockingWorkflowStepsComputed = (dashboardQuery.data?.workflowSteps ?? []).filter( (step) => step.status === "blocked" || step.status === "failed", ); + const recentReviews = dashboardQuery.data?.pullRequest.recentReviews ?? []; + const displayedRecentReviews = recentReviews.slice(0, 3); // Inspector props helper const inspectorProps = { @@ -531,7 +561,58 @@ export function PrReviewShell({ )} >
-
+
+
+
+
+ Review decision +
+
+ {formatReviewDecision(dashboardQuery.data?.pullRequest.reviewDecision)} +
+
+
+
+
+ Recent maintainer reviews +
+ {displayedRecentReviews.length > 0 ? ( +
+ {displayedRecentReviews.map((review) => ( +
+
+
+ + {review.authorLogin} + + + {review.state.toLowerCase().replaceAll("_", " ")} + +
+ + {formatReviewTimestamp(review.submittedAt)} + +
+ {review.body.trim().length > 0 ? ( +

+ {review.body} +

+ ) : null} +
+ ))} +
+ ) : ( +
No maintainer reviews yet.
+ )} +
{ setReviewBody(value); - // Auto-expand when user starts typing if (value.trim().length > 0 && !actionRailExpanded) { setActionRailExpanded(true); } diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index a3ab15563..6569ebd66 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -384,6 +384,12 @@ const PROVIDER_AUTH_GUIDES: Record< verifyCmd: "Test Connection", note: "OpenClaw uses the gateway URL and password below rather than a local CLI login. A configured gateway unlocks it for new-thread selection.", }, + copilot: { + installCmd: "npm install -g @github/copilot", + authCmd: "copilot login", + verifyCmd: "gh auth status", + note: "GitHub Copilot must be installed and authenticated before it can appear in the thread picker.", + }, }; function getAuthenticationBadgeCopy(input: { @@ -811,6 +817,7 @@ function SettingsRouteView() { codex: Boolean(settings.codexBinaryPath || settings.codexHomePath), claudeAgent: Boolean(settings.claudeBinaryPath || settings.claudeAuthTokenHelperCommand), openclaw: Boolean(settings.openclawGatewayUrl || settings.openclawPassword), + copilot: Boolean(settings.copilotBinaryPath || settings.copilotConfigDir), }); const [selectedCustomModelProvider, setSelectedCustomModelProvider] = useState("codex"); @@ -820,6 +827,7 @@ function SettingsRouteView() { codex: "", claudeAgent: "", openclaw: "", + copilot: "", }); const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState< Partial> @@ -1187,12 +1195,14 @@ function SettingsRouteView() { codex: false, claudeAgent: false, openclaw: false, + copilot: false, }); setSelectedCustomModelProvider("codex"); setCustomModelInputByProvider({ codex: "", claudeAgent: "", openclaw: "", + copilot: "", }); setCustomModelErrorByProvider({}); @@ -2515,6 +2525,7 @@ function SettingsRouteView() { codex: false, claudeAgent: false, openclaw: false, + copilot: false, }); }} /> diff --git a/packages/contracts/src/prReview.ts b/packages/contracts/src/prReview.ts index 645426b37..aaf1e4b36 100644 --- a/packages/contracts/src/prReview.ts +++ b/packages/contracts/src/prReview.ts @@ -227,6 +227,14 @@ export const PrReviewSummary = Schema.Struct({ statusChecks: Schema.Array(PrReviewStatusCheck), participants: Schema.Array(PrReviewParticipant), reviewRequests: Schema.Array(PrReviewParticipant), + recentReviews: Schema.Array( + Schema.Struct({ + authorLogin: TrimmedNonEmptyString, + state: TrimmedNonEmptyString, + body: Schema.String, + submittedAt: Schema.String, + }), + ), totalThreadCount: NonNegativeInt, unresolvedThreadCount: NonNegativeInt, headSha: Schema.NullOr(TrimmedNonEmptyString), From 37b1129dd4bcf3fac79208811836ddcac1e98116 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Mon, 13 Apr 2026 00:08:43 -0500 Subject: [PATCH 3/3] Reject unsupported SME chat providers in the flow - Block non-Claude providers before conversation creation - Disable unsupported options in the SME chat dialog - Add coverage for the provider validation path --- DESIGN.md | 59 ++++++++++--------- .../src/sme/Layers/SmeChatServiceLive.test.ts | 36 ++++++++++- .../src/sme/Layers/SmeChatServiceLive.ts | 26 ++++---- apps/server/src/sme/backends/anthropic.ts | 7 ++- .../components/sme/SmeConversationDialog.tsx | 12 +++- 5 files changed, 92 insertions(+), 48 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 62c9b4d18..c8f5324d9 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -52,34 +52,34 @@ conversation with the agent. Every pixel must earn its place. ### Typography -| Role | Size | Weight | Tracking | Font Stack | -|------|------|--------|----------|------------| -| Thread title (sidebar) | `text-xs` (0.75rem) | `font-normal` | default | Inter, system-ui, sans-serif | -| Thread subtitle / metadata | `text-[10px]` | `font-normal` | default | Inter, system-ui, sans-serif | -| Badge text | `text-[10px]` | `font-medium` | default | Inter, system-ui, sans-serif | -| Button text | `text-sm` (0.875rem) | `font-medium` | default | Inter, system-ui, sans-serif | -| Heading / dialog title | `text-lg` (1.125rem) | `font-semibold` | `-0.01em` | Inter, system-ui, sans-serif | -| Code / terminal | `text-sm` | `font-normal` | default | SF Mono, Consolas, monospace | -| Project name | `text-xs` | `font-semibold` | default | Inter, system-ui, sans-serif | +| Role | Size | Weight | Tracking | Font Stack | +| -------------------------- | -------------------- | --------------- | --------- | ---------------------------- | +| Thread title (sidebar) | `text-xs` (0.75rem) | `font-normal` | default | Inter, system-ui, sans-serif | +| Thread subtitle / metadata | `text-[10px]` | `font-normal` | default | Inter, system-ui, sans-serif | +| Badge text | `text-[10px]` | `font-medium` | default | Inter, system-ui, sans-serif | +| Button text | `text-sm` (0.875rem) | `font-medium` | default | Inter, system-ui, sans-serif | +| Heading / dialog title | `text-lg` (1.125rem) | `font-semibold` | `-0.01em` | Inter, system-ui, sans-serif | +| Code / terminal | `text-sm` | `font-normal` | default | SF Mono, Consolas, monospace | +| Project name | `text-xs` | `font-semibold` | default | Inter, system-ui, sans-serif | ### Color Semantics Colors are referenced through CSS custom properties, never hardcoded hex values. -| Token | Usage | -|-------|-------| -| `text-foreground` | Primary text | -| `text-muted-foreground` | Secondary/deemphasized text | +| Token | Usage | +| -------------------------- | ------------------------------------------------- | +| `text-foreground` | Primary text | +| `text-muted-foreground` | Secondary/deemphasized text | | `text-muted-foreground/50` | Tertiary/metadata text (branch names, timestamps) | -| `bg-background` | Page background | -| `bg-accent` | Hover state, active row highlight | -| `bg-accent/60` | Active sidebar item | -| `bg-accent/40` | Selected sidebar item | -| `text-emerald-600` | Additions / success (green) | -| `text-rose-500` | Deletions / error (red) | -| `text-warning` | Warning states, behind-upstream | -| `text-destructive` | Destructive actions (delete) | -| `border-border/60` | Subtle badge borders | +| `bg-background` | Page background | +| `bg-accent` | Hover state, active row highlight | +| `bg-accent/60` | Active sidebar item | +| `bg-accent/40` | Selected sidebar item | +| `text-emerald-600` | Additions / success (green) | +| `text-rose-500` | Deletions / error (red) | +| `text-warning` | Warning states, behind-upstream | +| `text-destructive` | Destructive actions (delete) | +| `border-border/60` | Subtle badge borders | ### Spacing Rules @@ -95,13 +95,13 @@ Colors are referenced through CSS custom properties, never hardcoded hex values. Five premium themes, each with light and dark variants: -| Theme | Vibe | -|-------|------| -| **Iridescent Void** | Futuristic, expensive, slightly alien | -| **Carbon** | Stark, modern, performance-focused | -| **Vapor** | Refined, fluid, purposeful | -| **Cotton Candy** | Sweet, dreamy, pink and blue | -| **Cathedral Circuit** | Sacred machine, techno-gothic | +| Theme | Vibe | +| --------------------- | ------------------------------------- | +| **Iridescent Void** | Futuristic, expensive, slightly alien | +| **Carbon** | Stark, modern, performance-focused | +| **Vapor** | Refined, fluid, purposeful | +| **Cotton Candy** | Sweet, dreamy, pink and blue | +| **Cathedral Circuit** | Sacred machine, techno-gothic | All themes define the same set of CSS custom properties. Components must use semantic tokens (`bg-accent`, `text-muted-foreground`) — never theme-specific values. @@ -249,6 +249,7 @@ a single flow: ``` Quick action resolves automatically based on git state: + - Has changes + no PR → "Commit, push & PR" - Has changes + existing PR → "Commit & push" - No changes + ahead → "Push & create PR" diff --git a/apps/server/src/sme/Layers/SmeChatServiceLive.test.ts b/apps/server/src/sme/Layers/SmeChatServiceLive.test.ts index 80048f7cc..8856be8fb 100644 --- a/apps/server/src/sme/Layers/SmeChatServiceLive.test.ts +++ b/apps/server/src/sme/Layers/SmeChatServiceLive.test.ts @@ -123,7 +123,9 @@ describe("SmeChatServiceLive", () => { const layer = makeSmeChatServiceLive({ sendSmeViaAnthropic: sendClaudeMessage }).pipe( Layer.provideMerge(Layer.succeed(SmeKnowledgeDocumentRepository, makeDocumentRepository())), - Layer.provideMerge(Layer.succeed(SmeConversationRepository, makeConversationRepository([conversationRow]))), + Layer.provideMerge( + Layer.succeed(SmeConversationRepository, makeConversationRepository([conversationRow])), + ), Layer.provideMerge(Layer.succeed(SmeMessageRepository, messageRepo)), ); @@ -249,9 +251,13 @@ describe("SmeChatServiceLive", () => { deletedAt: null, }; const { repository: messageRepo, rowsByConversation } = makeMessageRepository(); - const layer = makeSmeChatServiceLive({ sendSmeViaAnthropic: () => Effect.die("unexpected send") }).pipe( + const layer = makeSmeChatServiceLive({ + sendSmeViaAnthropic: () => Effect.succeed("unexpected send"), + }).pipe( Layer.provideMerge(Layer.succeed(SmeKnowledgeDocumentRepository, makeDocumentRepository())), - Layer.provideMerge(Layer.succeed(SmeConversationRepository, makeConversationRepository([conversationRow]))), + Layer.provideMerge( + Layer.succeed(SmeConversationRepository, makeConversationRepository([conversationRow])), + ), Layer.provideMerge(Layer.succeed(SmeMessageRepository, messageRepo)), ); @@ -311,4 +317,28 @@ describe("SmeChatServiceLive", () => { }), ]); }); + + it("rejects unsupported SME providers before a conversation can be created", async () => { + const projectId = ProjectId.makeUnsafe("project-3"); + const layer = makeSmeChatServiceLive().pipe( + Layer.provideMerge(Layer.succeed(SmeKnowledgeDocumentRepository, makeDocumentRepository())), + Layer.provideMerge(Layer.succeed(SmeConversationRepository, makeConversationRepository([]))), + Layer.provideMerge(Layer.succeed(SmeMessageRepository, makeMessageRepository().repository)), + ); + + await expect( + Effect.runPromise( + Effect.gen(function* () { + const service = yield* SmeChatService; + yield* service.createConversation({ + projectId, + title: "Unsupported provider", + provider: "codex", + authMethod: "chatgpt", + model: "codex-mini", + }); + }).pipe(Effect.provide(layer)), + ), + ).rejects.toThrow("SME Chat only supports Claude Code conversations right now."); + }); }); diff --git a/apps/server/src/sme/Layers/SmeChatServiceLive.ts b/apps/server/src/sme/Layers/SmeChatServiceLive.ts index c1f53c569..1c4b09a6b 100644 --- a/apps/server/src/sme/Layers/SmeChatServiceLive.ts +++ b/apps/server/src/sme/Layers/SmeChatServiceLive.ts @@ -7,7 +7,6 @@ * @module SmeChatServiceLive */ import type { - ProviderStartOptions, SmeAuthMethod, SmeConversation, SmeKnowledgeDocument, @@ -25,10 +24,7 @@ import { SmeConversationRepository } from "../../persistence/Services/SmeConvers import { SmeKnowledgeDocumentRepository } from "../../persistence/Services/SmeKnowledgeDocuments.ts"; import { SmeMessageRepository } from "../../persistence/Services/SmeMessages.ts"; import { isValidSmeAuthMethod } from "../authValidation.ts"; -import { - resolveAnthropicClientOptions, - sendSmeViaAnthropic, -} from "../backends/anthropic.ts"; +import { resolveAnthropicClientOptions, sendSmeViaAnthropic } from "../backends/anthropic.ts"; import { buildSmeSystemPrompt } from "../promptBuilder.ts"; import { SmeChatError, @@ -192,7 +188,8 @@ const makeSmeChatService = (options?: SmeChatServiceLiveOptions) => ? "Claude SME Chat can use the configured Anthropic API key." : "Claude SME Chat can use the configured Anthropic auth token.", resolvedAuthMethod: conversation.authMethod, - resolvedAccountType: clientOptions.apiKey !== null ? "apiKey" : "unknown", + resolvedAccountType: + clientOptions.apiKey !== null ? ("apiKey" as const) : ("unknown" as const), }; }); @@ -462,8 +459,7 @@ const makeSmeChatService = (options?: SmeChatServiceLiveOptions) => resolveAnthropicClientOptions({ providerOptions: input.providerOptions?.claudeAgent, }), - catch: (cause) => - new SmeChatError("sendMessage:providerRuntime", String(cause), cause), + catch: (cause) => new SmeChatError("sendMessage:providerRuntime", String(cause), cause), }); if (!anthropicClientOptions.apiKey && !anthropicClientOptions.authToken) { @@ -476,13 +472,13 @@ const makeSmeChatService = (options?: SmeChatServiceLiveOptions) => } const systemPrompt = buildSmeSystemPrompt(docs); - const messages: ReadonlyArray = [ - ...promptHistory + const messages: Array = [ + ...(promptHistory .filter((message) => message.role === "user" || message.role === "assistant") .map((message) => ({ role: message.role, content: message.text, - })) as Array, + })) as Array), { role: "user", content: input.text, @@ -502,13 +498,19 @@ const makeSmeChatService = (options?: SmeChatServiceLiveOptions) => abortSignal: abortController.signal, }); - yield* input.setInterruptEffect( + yield* setInterrupt( + input.conversationId, Effect.sync(() => { abortController.abort(); }), ); const responseText = yield* sendEffect.pipe( + Effect.ensuring( + Effect.gen(function* () { + yield* clearInterrupt(input.conversationId); + }), + ), Effect.mapError((cause) => cause instanceof SmeChatError ? cause diff --git a/apps/server/src/sme/backends/anthropic.ts b/apps/server/src/sme/backends/anthropic.ts index 6815eeb6c..e9ea30c3b 100644 --- a/apps/server/src/sme/backends/anthropic.ts +++ b/apps/server/src/sme/backends/anthropic.ts @@ -42,7 +42,7 @@ export function resolveAnthropicClientOptions( const baseURL = nonEmptyTrimmed(env.ANTHROPIC_BASE_URL ?? env.ANTHROPIC_API_BASE_URL); return { - apiKey: authToken ? null : explicitApiKey ?? null, + apiKey: authToken ? null : (explicitApiKey ?? null), authToken: authToken ?? null, ...(baseURL ? { baseURL } : {}), }; @@ -58,7 +58,7 @@ function createAnthropicClient(options: ResolvedAnthropicClientOptions): Anthrop export interface SendSmeViaAnthropicInput { readonly client?: AnthropicMessagesClient; - readonly messages: ReadonlyArray; + readonly messages: Array; readonly conversationId: string; readonly assistantMessageId: string; readonly model: string; @@ -73,7 +73,8 @@ export function sendSmeViaAnthropic(input: SendSmeViaAnthropicInput) { try: async () => { let result = ""; const client = - input.client ?? createAnthropicClient(input.clientOptions ?? resolveAnthropicClientOptions()); + input.client ?? + createAnthropicClient(input.clientOptions ?? resolveAnthropicClientOptions()); const stream = client.messages.stream( { model: input.model, diff --git a/apps/web/src/components/sme/SmeConversationDialog.tsx b/apps/web/src/components/sme/SmeConversationDialog.tsx index b88c7a3ff..facc8372e 100644 --- a/apps/web/src/components/sme/SmeConversationDialog.tsx +++ b/apps/web/src/components/sme/SmeConversationDialog.tsx @@ -35,6 +35,8 @@ import { SME_PROVIDER_LABELS, } from "./smeConversationConfig"; +const SME_CHAT_SUPPORTED_PROVIDERS = new Set(["claudeAgent"]); + interface SmeConversationDialogProps { open: boolean; onOpenChange: (open: boolean) => void; @@ -216,11 +218,19 @@ export function SmeConversationDialog({ className="h-10 rounded-xl border border-border bg-background px-3 text-sm" > {(["claudeAgent", "codex", "openclaw"] as const).map((value) => ( - ))} +

+ Claude Code is the only SME Chat provider that currently supports direct replies + without tool workflows. +