Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion ConvosCore/Sources/ConvosCore/API/ConvosAPIClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -903,10 +903,15 @@ final class ConvosAPIClient: ConvosAPIClientProtocol, Sendable {

let (data, httpResponse) = try await performAuthenticatedRequest(request)

if !(200...299).contains(httpResponse.statusCode) {
Log.error("agents/join failed [\(httpResponse.statusCode)]: \(String(data: data, encoding: .utf8) ?? "nil data")")
}
switch httpResponse.statusCode {
case 200...299:
let decoder = JSONDecoder()
return try decoder.decode(ConvosAPI.AgentJoinResponse.self, from: data)
let response = try decoder.decode(ConvosAPI.AgentJoinResponse.self, from: data)
Log.info("agents/join succeeded: instanceId=\(response.instanceId ?? "nil") joined=\(response.joined) inboxIdPresent=\(response.inboxId != nil)")
return response
case 502:
throw APIError.agentProvisionFailed
case 503:
Expand Down Expand Up @@ -1266,11 +1271,20 @@ extension ConvosAPIClient {
data: Data,
httpResponse: HTTPURLResponse
) throws -> ConvosAPI.AgentTemplateGenerationResponse {
if !(200...299).contains(httpResponse.statusCode) {
let path: String = httpResponse.url?.path(percentEncoded: false) ?? "agent-template generation"
Log.error("\(path) failed [\(httpResponse.statusCode)]: \(String(data: data, encoding: .utf8) ?? "nil data")")
}
switch httpResponse.statusCode {
case 200...299:
return try JSONDecoder().decode(ConvosAPI.AgentTemplateGenerationResponse.self, from: data)
case 400:
throw AgentGenerationError.badRequest(parseErrorMessage(from: data))
case 401, 403:
// Auth refresh already ran inside performAuthenticatedRequest, so a
// surfaced 401/403 won't heal on a plain retry - treat as terminal
// rather than looping through the retryable `.server` path.
throw AgentGenerationError.badRequest(parseErrorMessage(from: data) ?? "Not authorized")
case 404:
throw AgentGenerationError.notFound
case 409:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ public final class AgentTemplateRepository: AgentTemplateRepositoryProtocol {
createdAt: now,
updatedAt: now
)
Log.info("AgentTemplateRepository: starting generation \(idempotencyKey) for conversation \(conversationId)")
Task { [weak self] in
guard let self else { return }
do {
Expand Down Expand Up @@ -308,7 +309,8 @@ public final class AgentTemplateRepository: AgentTemplateRepositoryProtocol {
return await applyResponse(response, to: row.idempotencyKey)
} catch let error as AgentGenerationError {
switch error {
case .server:
case .server(let message):
Log.error("AgentTemplateRepository: submit server error (attempt \(attempt + 1)/\(Constant.maxSubmitAttempts)): \(message ?? "no message")")
attempt += 1
await backoff(attempt: attempt)
continue
Expand All @@ -325,6 +327,7 @@ public final class AgentTemplateRepository: AgentTemplateRepositoryProtocol {
return await markFailed(idempotencyKey: row.idempotencyKey, message: "Not found")
}
} catch {
Log.error("AgentTemplateRepository: submit failed (attempt \(attempt + 1)/\(Constant.maxSubmitAttempts)): \(error.localizedDescription)")
attempt += 1
await backoff(attempt: attempt)
continue
Expand All @@ -345,14 +348,19 @@ public final class AgentTemplateRepository: AgentTemplateRepositoryProtocol {
let response = try await apiClient.getAgentTemplateGeneration(generationId: generationId)
let updated = await applyResponse(response, to: row.idempotencyKey)
if let updated, updated.statusValue == .done || updated.statusValue == .failed {
if updated.statusValue == .failed {
Log.error("AgentTemplateRepository: generation \(generationId) reported failed by backend: \(updated.errorMessage ?? "no error message")")
}
return updated
}
} catch let error as AgentGenerationError {
if case .notFound = error {
return await markFailed(idempotencyKey: row.idempotencyKey, message: "Build expired")
}
Log.warning("AgentTemplateRepository: poll error for generation \(generationId) (attempt \(attempt + 1)/\(Constant.maxPollAttempts)): \(error)")
} catch {
// network blip - keep polling
Log.warning("AgentTemplateRepository: poll transient error for generation \(generationId) (attempt \(attempt + 1)/\(Constant.maxPollAttempts)): \(error.localizedDescription)")
}
attempt += 1
await backoff(attempt: min(attempt, Constant.pollBackoffCap))
Expand All @@ -368,6 +376,7 @@ public final class AgentTemplateRepository: AgentTemplateRepositoryProtocol {
_ = await markFailed(idempotencyKey: row.idempotencyKey, message: "Missing template id")
return
}
Log.info("AgentTemplateRepository: inviting template \(templateId) into conversation \(row.conversationId)")
var attempt: Int = 0
while attempt < Constant.maxInviteAttempts {
do {
Expand All @@ -380,6 +389,9 @@ public final class AgentTemplateRepository: AgentTemplateRepositoryProtocol {
if let handler {
try await handler(row.conversationId, templateId, row.variantId)
} else {
// Reaching this outside tests means the agent gets provisioned
// but never direct-added, so it silently never joins.
Log.warning("AgentTemplateRepository: join handler unset; raw join without direct-add for template \(templateId)")
let options = row.variantId.map { ConvosAPI.AgentJoinOptions(onboarding: nil, variantId: $0) }
_ = try await apiClient.requestAgentJoin(
slug: nil,
Expand All @@ -390,6 +402,7 @@ public final class AgentTemplateRepository: AgentTemplateRepositoryProtocol {
forceErrorCode: nil
)
}
Log.info("AgentTemplateRepository: invite succeeded for template \(templateId) in conversation \(row.conversationId)")
Self.cleanupAttachments(idempotencyKey: row.idempotencyKey)
_ = await updateRow(idempotencyKey: row.idempotencyKey) { $0.status = DBAgentTemplateGeneration.Status.invited.rawValue }
return
Expand Down Expand Up @@ -423,6 +436,7 @@ public final class AgentTemplateRepository: AgentTemplateRepositoryProtocol {
to idempotencyKey: String
) async -> DBAgentTemplateGeneration? {
await updateRow(idempotencyKey: idempotencyKey) { row in
let previousStatus = row.status
row.generationId = response.generationId
row.templateId = response.templateId ?? row.templateId
switch response.status {
Expand All @@ -447,6 +461,9 @@ public final class AgentTemplateRepository: AgentTemplateRepositoryProtocol {
if let phrases = response.progressPhrases, !phrases.isEmpty {
row.progressPhrases = Self.encodePhrases(phrases)
}
if row.status != previousStatus {
Log.info("AgentTemplateRepository: generation \(response.generationId) status \(previousStatus) -> \(row.status)")
}
}
}

Expand Down Expand Up @@ -572,6 +589,7 @@ public final class AgentTemplateRepository: AgentTemplateRepositoryProtocol {
}

private func markFailed(idempotencyKey: String, message: String) async -> DBAgentTemplateGeneration? {
Log.error("AgentTemplateRepository: generation \(idempotencyKey) marked failed: \(message)")
Self.cleanupAttachments(idempotencyKey: idempotencyKey)
return await updateRow(idempotencyKey: idempotencyKey) { row in
row.status = DBAgentTemplateGeneration.Status.failed.rawValue
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1329,6 +1329,7 @@ extension SessionManager {
)
let instanceId = resolved.instanceId
let agentInboxId = resolved.inboxId
Log.info("Direct-add: provisioned instance \(instanceId) with inbox \(agentInboxId); adding to conversation \(conversationId)")

// 2. Add: a plain member add by this device — synchronous, no DM
// round-trip, no online-accepter dependency. The add durably
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,49 @@ struct AgentTemplateRepositoryTests {
#expect(api.generationCalls == 0)
#expect(api.joinCalls == 0)
}

@Test("Generation 401/403 map to terminal badRequest, 5xx stays retryable server")
func generationAuthFailuresAreTerminal() throws {
// A surfaced 401/403 already exhausted the auth refresh inside
// performAuthenticatedRequest, so a plain retry can't heal it. Mapping
// it to the retryable `.server` case made the builder retry silently
// and then fail with a generic "Couldn't reach the builder".
let api = try #require(ConvosAPIClientFactory.client(
environment: .local(config: ConvosConfiguration(
apiBaseURL: "https://api.example.com",
appGroupIdentifier: "group.test",
relyingPartyIdentifier: "example.com",
siweConfiguration: SIWEConfiguration(domain: "example.com", uri: "https://example.com", chainId: 1)
))
) as? ConvosAPIClient)
func decode(status: Int, body: String = #"{"error":"Account required"}"#) throws -> ConvosAPI.AgentTemplateGenerationResponse {
let url = try #require(URL(string: "https://api.example.com/api/v2/agent-templates/generations"))
let response = try #require(HTTPURLResponse(url: url, statusCode: status, httpVersion: nil, headerFields: nil))
return try api.decodeGenerationResponse(data: Data(body.utf8), httpResponse: response)
}

for status in [401, 403] {
#expect(throws: AgentGenerationError.self) { try decode(status: status) }
do {
_ = try decode(status: status)
} catch let error as AgentGenerationError {
guard case .badRequest(let message) = error else {
Issue.record("Expected .badRequest for \(status), got \(error)")
return
}
#expect(message == "Account required")
}
}

do {
_ = try decode(status: 500)
} catch let error as AgentGenerationError {
guard case .server = error else {
Issue.record("Expected .server for 500, got \(error)")
return
}
}
}
}

/// Submit returns pending, the first poll returns done, and the join records
Expand Down
Loading