Implement optional discussion mode

This commit is contained in:
diyaa
2026-09-13 21:13:52 +02:00
parent bf1ccbe8a9
commit a39bdbe6fc
12 changed files with 291 additions and 1 deletions
@@ -24,6 +24,24 @@ final class AIServiceTests: XCTestCase {
XCTAssertEqual(result.followUpQuestions, ["Should the chorus be bigger?"])
}
func testProviderIndependentServiceReturnsDiscussionQuestionsWithoutProjectUpdate() async throws {
let service = MockAIService()
let project = SongProject(
title: "Discussion Project",
idea: "Plan the chorus",
conversationMode: .discuss
)
let request = SongProjectDiscussionRequest(
context: AIRequestContext(userInstruction: "Ask what is still missing."),
project: project
)
let result = try await service.discussSongProject(from: request)
XCTAssertEqual(result.questions, ["Which vocal delivery should lead the chorus?"])
XCTAssertEqual(result.notes, ["Discussion only; no project update was proposed."])
}
func testProviderIndependentServiceRevisesLyricsWithStructuredResult() async throws {
let service = MockAIService()
let project = SongProject(title: "Lyric Project", idea: "Improve words")
@@ -71,6 +89,13 @@ private struct MockAIService: AIService {
)
}
func discussSongProject(from request: SongProjectDiscussionRequest) async throws -> SongProjectDiscussionResult {
SongProjectDiscussionResult(
questions: ["Which vocal delivery should lead the chorus?"],
notes: ["Discussion only; no project update was proposed."]
)
}
func reviseLyrics(from request: LyricsRevisionRequest) async throws -> LyricsRevisionResult {
LyricsRevisionResult(
lyrics: Lyrics(text: "\(request.sourceLyrics.text)\n\(request.context.userInstruction)"),
@@ -17,6 +17,12 @@ final class ApplicationRuleInjectionTests: XCTestCase {
context: AIRequestContext(userInstruction: "Generate.")
)
)
_ = try await service.discussSongProject(
from: SongProjectDiscussionRequest(
context: AIRequestContext(userInstruction: "Discuss."),
project: project
)
)
_ = try await service.reviseLyrics(
from: LyricsRevisionRequest(
context: AIRequestContext(userInstruction: "Improve."),
@@ -37,6 +43,7 @@ final class ApplicationRuleInjectionTests: XCTestCase {
XCTAssertEqual(
recordedRuleContents,
[
"Use private product rules.",
"Use private product rules.",
"Use private product rules.",
"Use private product rules."
@@ -90,6 +97,11 @@ private actor RecordingAIService: AIService {
)
}
func discussSongProject(from request: SongProjectDiscussionRequest) async throws -> SongProjectDiscussionResult {
contexts.append(request.context)
return SongProjectDiscussionResult(questions: ["Which direction should we take?"])
}
func reviseLyrics(from request: LyricsRevisionRequest) async throws -> LyricsRevisionResult {
contexts.append(request.context)
return LyricsRevisionResult(lyrics: request.sourceLyrics)
@@ -68,6 +68,34 @@ final class OpenAIClientTests: XCTestCase {
XCTAssertEqual(recordedURL, overrideURL)
}
func testDiscussSongProjectUsesAdapterAndReturnsQuestions() async throws {
let transport = RecordingOpenAITransport(
statusCode: 200,
responseData: try JSONEncoder().encode(MockOpenAIResponse(text: "Should the chorus be intimate or anthemic?"))
)
let configuration = OpenAIClientConfiguration(
apiKey: "test-api-key",
endpointURL: URL(string: "https://api.example.test/v1/configured")!,
model: "configured-model"
)
let client = OpenAIClient(
configuration: configuration,
adapter: MockOpenAIClientAdapter(),
transport: transport
)
let result = try await client.discussSongProject(
from: SongProjectDiscussionRequest(
context: AIRequestContext(userInstruction: "Ask before deciding."),
project: SongProject(title: "Discussion", idea: "Plan this", conversationMode: .discuss)
)
)
let requestBodyString = await transport.requestBodyString
XCTAssertEqual(result.questions, ["Should the chorus be intimate or anthemic?"])
XCTAssertEqual(requestBodyString, "discuss|configured-model|Ask before deciding.")
}
func testClientRejectsMissingAPIKeyBeforeSendingRequest() async throws {
let transport = RecordingOpenAITransport(
statusCode: 200,
@@ -175,6 +203,21 @@ private struct MockOpenAIClientAdapter: OpenAIClientAdapter {
)
}
func makeSongProjectDiscussionRequest(
_ request: SongProjectDiscussionRequest,
configuration: OpenAIClientConfiguration
) throws -> OpenAIClientRequest {
OpenAIClientRequest(
url: overrideURL,
body: Data("discuss|\(configuration.model ?? "")|\(request.context.userInstruction)".utf8)
)
}
func decodeSongProjectDiscussionResult(from data: Data) throws -> SongProjectDiscussionResult {
let response = try JSONDecoder().decode(MockOpenAIResponse.self, from: data)
return SongProjectDiscussionResult(questions: [response.text])
}
func makeLyricsRevisionRequest(
_ request: LyricsRevisionRequest,
configuration: OpenAIClientConfiguration
@@ -0,0 +1,94 @@
import MusicAssistantCore
import XCTest
final class SongProjectDiscussionDirectorTests: XCTestCase {
func testDiscussBuildsARequestAndReturnsTrimmedQuestionsWithoutChangingProject() async throws {
let project = SongProject(
id: "discussion-project",
title: "Discussion Project",
idea: "Plan a cinematic chorus",
conversationMode: .discuss,
createdAt: Date(timeIntervalSince1970: 10),
updatedAt: Date(timeIntervalSince1970: 20)
)
let aiService = RecordingDiscussionAIService(
result: SongProjectDiscussionResult(
questions: [" Should the chorus use a choir? ", " "],
notes: ["Awaiting the user's choice."]
)
)
let director = SongProjectDiscussionDirector(aiService: aiService)
let conversation = [AIConversationMessage(role: .assistant, content: "What mood should lead?")]
let result = try await director.discuss(
project: project,
userMessage: " Keep the verses intimate. ",
conversation: conversation,
localeIdentifier: "en_US"
)
let request = await aiService.recordedRequests.first
XCTAssertEqual(request?.context.userInstruction, "Keep the verses intimate.")
XCTAssertEqual(request?.context.conversation, conversation)
XCTAssertEqual(request?.context.localeIdentifier, "en_US")
XCTAssertEqual(request?.project, project)
XCTAssertEqual(result.questions, ["Should the chorus use a choir?"])
XCTAssertEqual(result.notes, ["Awaiting the user's choice."])
}
func testDiscussRejectsAutoModeAndBlankMessagesBeforeCallingAI() async throws {
let aiService = RecordingDiscussionAIService(
result: SongProjectDiscussionResult(questions: ["Unused"])
)
let director = SongProjectDiscussionDirector(aiService: aiService)
do {
_ = try await director.discuss(
project: SongProject(title: "Auto", idea: "Auto", conversationMode: .auto),
userMessage: "Ask a question."
)
XCTFail("Expected discussion mode validation to fail.")
} catch let error as SongProjectDiscussionDirectorError {
XCTAssertEqual(error, .discussionModeNotEnabled)
}
do {
_ = try await director.discuss(
project: SongProject(title: "Discuss", idea: "Discuss", conversationMode: .discuss),
userMessage: " \n "
)
XCTFail("Expected blank message validation to fail.")
} catch let error as SongProjectDiscussionDirectorError {
XCTAssertEqual(error, .emptyUserMessage)
}
let requestCount = await aiService.recordedRequests.count
XCTAssertEqual(requestCount, 0)
}
}
private actor RecordingDiscussionAIService: AIService {
private(set) var recordedRequests: [SongProjectDiscussionRequest] = []
private let result: SongProjectDiscussionResult
init(result: SongProjectDiscussionResult) {
self.result = result
}
func generateSongProject(from request: SongProjectGenerationRequest) async throws -> SongProjectGenerationResult {
SongProjectGenerationResult(project: request.seedProject ?? SongProject(title: "Unused", idea: "Unused"))
}
func discussSongProject(from request: SongProjectDiscussionRequest) async throws -> SongProjectDiscussionResult {
recordedRequests.append(request)
return result
}
func reviseLyrics(from request: LyricsRevisionRequest) async throws -> LyricsRevisionResult {
LyricsRevisionResult(lyrics: request.sourceLyrics)
}
func proposeProjectUpdate(from request: SongProjectUpdateRequest) async throws -> SongProjectUpdateResult {
SongProjectUpdateResult(project: request.project)
}
}