3de665d254
Add the AI keyboard tab, Agent settings, and user-owned LLM key path for 1.7.0, including streaming answers and web-search transports without the built-in DeepSeek fallback.
220 lines
7.5 KiB
Swift
220 lines
7.5 KiB
Swift
import XCTest
|
|
@testable import OSGKeyboardShared
|
|
|
|
final class AIQuestionServiceTests: XCTestCase {
|
|
func testSuccessfulTurnsAreRetainedAndOldestTurnIsTrimmed() async throws {
|
|
let client = CapturingAIClient()
|
|
let conversations = AIConversationStore()
|
|
let service = AIQuestionService(client: client, conversations: conversations)
|
|
let conversationID = UUID()
|
|
|
|
for index in 0...AIQuestionLimits.retainedConversationRounds {
|
|
client.nextAnswer = "答案\(index)"
|
|
let answer = try await service.answer(
|
|
question: "问题\(index)",
|
|
conversationID: conversationID,
|
|
targetLocaleID: TranslationLanguageCatalog.offLocaleId
|
|
)
|
|
await service.commitSuccessfulTurn(
|
|
question: "问题\(index)",
|
|
answer: answer,
|
|
conversationID: conversationID
|
|
)
|
|
}
|
|
|
|
let turns = await conversations.turns(for: conversationID)
|
|
XCTAssertEqual(turns.count, AIQuestionLimits.retainedConversationRounds)
|
|
XCTAssertEqual(turns.first?.question, "问题1")
|
|
XCTAssertEqual(turns.last?.answer, "答案6")
|
|
}
|
|
|
|
func testQuestionIsPassedWithoutCleanup() async throws {
|
|
let client = CapturingAIClient()
|
|
let service = AIQuestionService(
|
|
client: client,
|
|
conversations: AIConversationStore()
|
|
)
|
|
let rawQuestion = "嗯 帮我回答这个?"
|
|
|
|
_ = try await service.answer(
|
|
question: rawQuestion,
|
|
conversationID: UUID(),
|
|
targetLocaleID: "en"
|
|
)
|
|
|
|
XCTAssertEqual(client.lastMessages?.last, .user(rawQuestion))
|
|
XCTAssertTrue(client.lastMessages?.first?.content.contains("Reply in English.") == true)
|
|
}
|
|
|
|
func testAnswerDoesNotEnterContextUntilHostCommitsTerminalResult() async throws {
|
|
let client = CapturingAIClient()
|
|
let conversations = AIConversationStore()
|
|
let service = AIQuestionService(client: client, conversations: conversations)
|
|
let conversationID = UUID()
|
|
|
|
let answer = try await service.answer(
|
|
question: "会被取消的问题",
|
|
conversationID: conversationID,
|
|
targetLocaleID: TranslationLanguageCatalog.offLocaleId
|
|
)
|
|
|
|
let turnsBeforeCommit = await conversations.turns(for: conversationID)
|
|
XCTAssertTrue(turnsBeforeCommit.isEmpty)
|
|
await service.commitSuccessfulTurn(
|
|
question: "会被取消的问题",
|
|
answer: answer,
|
|
conversationID: conversationID
|
|
)
|
|
let turnsAfterCommit = await conversations.turns(for: conversationID)
|
|
XCTAssertEqual(turnsAfterCommit.count, 1)
|
|
}
|
|
|
|
func testAnswerIsBoundedByCharacterCount() {
|
|
let oversized = String(repeating: "答", count: AIQuestionLimits.maximumAnswerCharacterCount + 100)
|
|
|
|
let result = AIQuestionService.boundedAnswer(oversized)
|
|
|
|
XCTAssertEqual(result.count, AIQuestionLimits.maximumAnswerCharacterCount)
|
|
XCTAssertTrue(result.hasSuffix("…"))
|
|
}
|
|
|
|
func testSystemPromptIncludesResponseLengthGuidance() {
|
|
let shortPrompt = AIQuestionPromptComposer.systemPrompt(
|
|
targetLocaleID: TranslationLanguageCatalog.offLocaleId,
|
|
responseLength: .short
|
|
)
|
|
let mediumPrompt = AIQuestionPromptComposer.systemPrompt(
|
|
targetLocaleID: TranslationLanguageCatalog.offLocaleId,
|
|
responseLength: .medium
|
|
)
|
|
let detailedPrompt = AIQuestionPromptComposer.systemPrompt(
|
|
targetLocaleID: TranslationLanguageCatalog.offLocaleId,
|
|
responseLength: .detailed
|
|
)
|
|
|
|
XCTAssertTrue(shortPrompt.contains(AIResponseLength.short.promptGuidance))
|
|
XCTAssertTrue(mediumPrompt.contains(AIResponseLength.medium.promptGuidance))
|
|
XCTAssertTrue(detailedPrompt.contains(AIResponseLength.detailed.promptGuidance))
|
|
XCTAssertTrue(mediumPrompt.contains("Treat the length guidance as a preference"))
|
|
}
|
|
|
|
func testAnswerUsesConfiguredResponseLengthInSystemPrompt() async throws {
|
|
let client = CapturingAIClient()
|
|
let service = AIQuestionService(
|
|
client: client,
|
|
conversations: AIConversationStore(),
|
|
responseLength: .short
|
|
)
|
|
|
|
_ = try await service.answer(
|
|
question: "天气怎么样",
|
|
conversationID: UUID(),
|
|
targetLocaleID: TranslationLanguageCatalog.offLocaleId
|
|
)
|
|
|
|
XCTAssertTrue(
|
|
client.lastMessages?.first?.content.contains(AIResponseLength.short.promptGuidance) == true
|
|
)
|
|
}
|
|
|
|
func testStreamingPartialsAccumulateAndRestartClearsDraft() async throws {
|
|
let client = StreamingStubAIClient(events: [
|
|
.delta("你好"),
|
|
.delta(",世界"),
|
|
.restart,
|
|
.delta("最终答案"),
|
|
])
|
|
let service = AIQuestionService(
|
|
client: client,
|
|
conversations: AIConversationStore()
|
|
)
|
|
// Box avoids Swift 6 "mutation of captured var in concurrently-executing code".
|
|
final class PartialBox: @unchecked Sendable {
|
|
var values: [String] = []
|
|
}
|
|
let partials = PartialBox()
|
|
|
|
let answer = try await service.answer(
|
|
question: "流式问题",
|
|
conversationID: UUID(),
|
|
targetLocaleID: TranslationLanguageCatalog.offLocaleId
|
|
) { partial in
|
|
partials.values.append(partial)
|
|
}
|
|
|
|
XCTAssertEqual(answer, "最终答案")
|
|
XCTAssertEqual(partials.values, ["你好", "你好,世界", "", "最终答案"])
|
|
}
|
|
|
|
func testStreamingPreviewSoftCapsWithoutEllipsis() {
|
|
let oversized = String(
|
|
repeating: "草",
|
|
count: AIQuestionLimits.maximumAnswerCharacterCount + 50
|
|
)
|
|
let preview = AIQuestionService.streamingPreview(oversized)
|
|
XCTAssertEqual(preview.count, AIQuestionLimits.maximumAnswerCharacterCount)
|
|
XCTAssertFalse(preview.hasSuffix("…"))
|
|
}
|
|
}
|
|
|
|
private final class CapturingAIClient: LLMClient, @unchecked Sendable {
|
|
var nextAnswer = "回答"
|
|
var lastMessages: [LLMRequest.Message]?
|
|
let requestTimeout: TimeInterval = 1
|
|
|
|
func polish(
|
|
_ text: String,
|
|
systemPrompt: String,
|
|
timeout: TimeInterval?
|
|
) async throws -> String {
|
|
nextAnswer
|
|
}
|
|
|
|
func complete(
|
|
messages: [LLMRequest.Message],
|
|
timeout: TimeInterval?,
|
|
options: LLMGenerationOptions
|
|
) async throws -> String {
|
|
lastMessages = messages
|
|
return nextAnswer
|
|
}
|
|
}
|
|
|
|
private final class StreamingStubAIClient: LLMClient, @unchecked Sendable {
|
|
let events: [LLMStreamEvent]
|
|
let requestTimeout: TimeInterval = 1
|
|
|
|
init(events: [LLMStreamEvent]) {
|
|
self.events = events
|
|
}
|
|
|
|
func polish(
|
|
_ text: String,
|
|
systemPrompt: String,
|
|
timeout: TimeInterval?
|
|
) async throws -> String {
|
|
"unused"
|
|
}
|
|
|
|
func complete(
|
|
messages: [LLMRequest.Message],
|
|
timeout: TimeInterval?,
|
|
options: LLMGenerationOptions
|
|
) async throws -> String {
|
|
"unused"
|
|
}
|
|
|
|
func completeStreaming(
|
|
messages: [LLMRequest.Message],
|
|
timeout: TimeInterval?,
|
|
options: LLMGenerationOptions
|
|
) -> AsyncThrowingStream<LLMStreamEvent, Error> {
|
|
AsyncThrowingStream { continuation in
|
|
for event in events {
|
|
continuation.yield(event)
|
|
}
|
|
continuation.finish()
|
|
}
|
|
}
|
|
}
|