Skip to content

Commit daea574

Browse files
committed
ai(cli): harden decoding + surface CLI errors
1 parent a125d11 commit daea574

1 file changed

Lines changed: 137 additions & 7 deletions

File tree

Dayflow/Dayflow/Core/AI/ChatCLIProvider.swift

Lines changed: 137 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -454,7 +454,86 @@ final class ChatCLIProvider {
454454

455455
// MARK: - Parsing
456456

457+
/// Strip OSC (Operating System Command) escape sequences from CLI output.
458+
/// These are injected by terminal integrations like iTerm2 and pollute JSON responses.
459+
/// Examples: ]1337;RemoteHost=user@host, ]9;4;0;, ]1337;CurrentDir=/path
460+
/// Safety: Only strips if semicolon appears within first 5 chars (real OSC always has it)
461+
private func stripOSCEscapes(_ input: String) -> String {
462+
var result = ""
463+
var i = input.startIndex
464+
while i < input.endIndex {
465+
if input[i] == "]" {
466+
let next = input.index(after: i)
467+
if next < input.endIndex, input[next].isNumber {
468+
// Look ahead to see if there's a semicolon within first 5 chars (OSC signature)
469+
var hasSemicolon = false
470+
var lookAhead = next
471+
var lookCount = 0
472+
while lookAhead < input.endIndex, lookCount < 5 {
473+
if input[lookAhead] == ";" {
474+
hasSemicolon = true
475+
break
476+
}
477+
if !input[lookAhead].isNumber { break }
478+
lookAhead = input.index(after: lookAhead)
479+
lookCount += 1
480+
}
481+
482+
if hasSemicolon {
483+
// This is a real OSC sequence - skip it
484+
var j = next
485+
while j < input.endIndex {
486+
let c = input[j]
487+
if c.isNumber || c == ";" || c == "=" || c.isLetter || c == "@" || c == "." || c == "-" || c == "_" || c == "/" {
488+
j = input.index(after: j)
489+
} else {
490+
break
491+
}
492+
}
493+
i = j
494+
continue
495+
}
496+
}
497+
}
498+
result.append(input[i])
499+
i = input.index(after: i)
500+
}
501+
return result
502+
}
503+
504+
/// Extract user-facing error message from CLI stderr/stdout.
505+
/// Returns the actual error message from the CLI tool if found, nil otherwise.
506+
private func extractCLIError(stdout: String, stderr: String) -> String? {
507+
// Check stderr for ERROR: lines (Codex format)
508+
// e.g. "ERROR: You've hit your usage limit..."
509+
// e.g. "ERROR: Your access token could not be refreshed..."
510+
for line in stderr.components(separatedBy: .newlines) {
511+
let trimmed = line.trimmingCharacters(in: .whitespaces)
512+
if trimmed.hasPrefix("ERROR:") {
513+
return trimmed
514+
}
515+
}
516+
517+
// Check stdout for API Error messages (Claude format)
518+
// e.g. "API Error: The SSO session associated with this profile has expired..."
519+
// e.g. "You've hit your limit · resets 3pm (Asia/Shanghai)"
520+
// e.g. "Invalid API key · Please run /login"
521+
for line in stdout.components(separatedBy: .newlines) {
522+
let trimmed = line.trimmingCharacters(in: .whitespaces)
523+
if trimmed.hasPrefix("API Error:") ||
524+
trimmed.hasPrefix("Invalid API key") ||
525+
trimmed.hasPrefix("You've hit your limit") {
526+
// Strip trailing escape sequences like ]9;4;0;
527+
let cleaned = trimmed.replacingOccurrences(of: #"\][\d;]+$"#, with: "", options: .regularExpression)
528+
return cleaned
529+
}
530+
}
531+
532+
return nil
533+
}
534+
457535
private func parseCards(from output: String, stderr: String) throws -> [ActivityCardData] {
536+
// Try parsing without modifications first, OSC stripping is a fallback
458537
guard let data = output.data(using: .utf8) else {
459538
throw NSError(domain: "ChatCLI", code: -31, userInfo: [NSLocalizedDescriptionKey: "No stdout to parse"])
460539
}
@@ -541,6 +620,22 @@ final class ChatCLIProvider {
541620
}
542621
}
543622

623+
// Strategy 4 (fallback): Strip OSC escapes and retry bracket extraction
624+
let oscCleaned = stripOSCEscapes(output)
625+
if let lastBracket = oscCleaned.lastIndex(of: "]"),
626+
let firstBracket = findBalancedArrayStart(oscCleaned, endBracket: lastBracket) {
627+
let sliced = String(oscCleaned[firstBracket...lastBracket])
628+
.replacingOccurrences(of: "```json", with: "")
629+
.replacingOccurrences(of: "```", with: "")
630+
.trimmingCharacters(in: .whitespacesAndNewlines)
631+
632+
if let slicedData = sliced.data(using: .utf8) {
633+
if let arrayCards = try? decoder.decode([ActivityCardData].self, from: slicedData) {
634+
return arrayCards
635+
}
636+
}
637+
}
638+
544639
// Log full raw output to PostHog for debugging decode failures
545640
AnalyticsService.shared.capture("llm_decode_failed", [
546641
"provider": "chat_cli",
@@ -552,6 +647,11 @@ final class ChatCLIProvider {
552647
"stderr_length": stderr.count
553648
])
554649

650+
// Surface CLI error messages to the user if available
651+
if let cliError = extractCLIError(stdout: output, stderr: stderr) {
652+
throw NSError(domain: "ChatCLI", code: -33, userInfo: [NSLocalizedDescriptionKey: cliError])
653+
}
654+
555655
throw NSError(domain: "ChatCLI", code: -32, userInfo: [NSLocalizedDescriptionKey: "Failed to decode activity cards"])
556656
}
557657

@@ -934,34 +1034,58 @@ final class ChatCLIProvider {
9341034
}
9351035

9361036
private func parseSegments(from output: String, stderr: String) throws -> [SegmentMergeResponse.Segment] {
937-
let cleaned = output
1037+
// First try parsing without any modifications
1038+
let basicCleaned = output
9381039
.replacingOccurrences(of: "```json", with: "")
9391040
.replacingOccurrences(of: "```", with: "")
9401041
.trimmingCharacters(in: .whitespacesAndNewlines)
9411042

942-
if let data = cleaned.data(using: .utf8),
1043+
var lastDecodeError: String?
1044+
1045+
// Strategy 1: Direct decode
1046+
if let data = basicCleaned.data(using: .utf8),
9431047
let parsed = try? JSONDecoder().decode(SegmentMergeResponse.self, from: data),
9441048
!parsed.segments.isEmpty {
9451049
return parsed.segments
9461050
}
9471051

948-
if let data = cleaned.data(using: .utf8),
1052+
// Strategy 2: Array decode
1053+
if let data = basicCleaned.data(using: .utf8),
9491054
let parsed = try? JSONDecoder().decode([SegmentMergeResponse.Segment].self, from: data),
9501055
!parsed.isEmpty {
9511056
return parsed
9521057
}
9531058

954-
if let firstBrace = cleaned.firstIndex(of: "{"),
955-
let lastBrace = cleaned.lastIndex(of: "}"),
1059+
// Strategy 3: Brace extraction
1060+
if let firstBrace = basicCleaned.firstIndex(of: "{"),
1061+
let lastBrace = basicCleaned.lastIndex(of: "}"),
9561062
firstBrace < lastBrace {
957-
let slice = String(cleaned[firstBrace...lastBrace])
1063+
let slice = String(basicCleaned[firstBrace...lastBrace])
9581064
if let data = slice.data(using: .utf8),
9591065
let parsed = try? JSONDecoder().decode(SegmentMergeResponse.self, from: data),
9601066
!parsed.segments.isEmpty {
9611067
return parsed.segments
9621068
}
9631069
}
9641070

1071+
// Strategy 4 (fallback): Strip OSC escapes and retry brace extraction
1072+
let oscCleaned = stripOSCEscapes(basicCleaned)
1073+
if let firstBrace = oscCleaned.firstIndex(of: "{"),
1074+
let lastBrace = oscCleaned.lastIndex(of: "}"),
1075+
firstBrace < lastBrace {
1076+
let slice = String(oscCleaned[firstBrace...lastBrace])
1077+
if let data = slice.data(using: .utf8) {
1078+
do {
1079+
let parsed = try JSONDecoder().decode(SegmentMergeResponse.self, from: data)
1080+
if !parsed.segments.isEmpty { return parsed.segments }
1081+
} catch {
1082+
lastDecodeError = "Strategy 4 (OSC strip + brace): \(error.localizedDescription)"
1083+
}
1084+
}
1085+
} else {
1086+
lastDecodeError = "No JSON object found in output"
1087+
}
1088+
9651089
// Log full raw output to PostHog for debugging decode failures
9661090
AnalyticsService.shared.capture("llm_decode_failed", [
9671091
"provider": "chat_cli",
@@ -970,9 +1094,15 @@ final class ChatCLIProvider {
9701094
"raw_output": output,
9711095
"output_length": output.count,
9721096
"stderr": stderr,
973-
"stderr_length": stderr.count
1097+
"stderr_length": stderr.count,
1098+
"decode_error": lastDecodeError ?? "no JSON found"
9741099
])
9751100

1101+
// Surface CLI error messages to the user if available
1102+
if let cliError = extractCLIError(stdout: output, stderr: stderr) {
1103+
throw NSError(domain: "ChatCLI", code: -33, userInfo: [NSLocalizedDescriptionKey: cliError])
1104+
}
1105+
9761106
throw NSError(domain: "ChatCLI", code: -31, userInfo: [NSLocalizedDescriptionKey: "Failed to decode segments JSON"])
9771107
}
9781108

0 commit comments

Comments
 (0)