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
1 change: 1 addition & 0 deletions Sources/Agent/AgentClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Foundation

public enum AgentResponsePart: Sendable {
case textDelta(String)
case reasoningDelta(String)
case message(Message)
case error(Error)
}
Expand Down
1 change: 1 addition & 0 deletions Sources/Agent/chat/openAIChatProcessor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ struct OpenAIChatProcessor {
currentAssistantReasoning = ""
}
currentAssistantReasoning! += reasoning
continuation.yield(.reasoningDelta(reasoning))
}

if let reasoningDetails = delta.reasoningDetails {
Expand Down
36 changes: 33 additions & 3 deletions Sources/Agent/chat/openaiChat.swift
Original file line number Diff line number Diff line change
Expand Up @@ -242,12 +242,39 @@ public struct OpenAIAssistantMessage: Hashable, Codable, Sendable {
}

public struct ReasoningDetail: Hashable, Codable, Sendable {
public enum ReasoningType: String, Codable, Sendable {
case summary = "reasoning.summary"
case text = "reasoning.text"
}

public let type: ReasoningType?
public let id: String?
public let format: String?

public init(id: String?, format: String?) {
public let index: Int?

// For summary type
public let summary: String?

// For text type
public let text: String?
public let signature: String?

public init(
type: ReasoningType? = nil,
id: String? = nil,
format: String? = nil,
index: Int? = nil,
summary: String? = nil,
text: String? = nil,
signature: String? = nil
) {
self.type = type
self.id = id
self.format = format
self.index = index
self.summary = summary
self.text = text
self.signature = signature
}
}

Expand Down Expand Up @@ -308,7 +335,10 @@ public struct OpenAIAssistantMessage: Hashable, Codable, Sendable {
try container.encode(role, forKey: .role)
try container.encodeIfPresent(content, forKey: .content)
try container.encodeIfPresent(toolCalls, forKey: .toolCalls)
// Exclude id, audio, reasoning, reasoningDetails - not part of request spec
// Note: id and audio are excluded - not part of OpenAI API spec
// But reasoning and reasoningDetails are included for persistence
try container.encodeIfPresent(reasoning, forKey: .reasoning)
try container.encodeIfPresent(reasoningDetails, forKey: .reasoningDetails)
}
}

Expand Down
72 changes: 70 additions & 2 deletions Sources/AgentLayout/ChatProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ public class ChatProvider: ChatProviderProtocol {

var currentAssistantId = UUID().uuidString
var currentAssistantContent = ""
var currentAssistantReasoning = ""

let initialMsg = Message.openai(
.assistant(
Expand All @@ -258,10 +259,42 @@ public class ChatProvider: ChatProviderProtocol {
if Task.isCancelled { break }

switch part {
case .reasoningDelta(let reasoning):
if self.currentStreamingMessageId == nil {
currentAssistantId = UUID().uuidString
currentAssistantContent = ""
currentAssistantReasoning = ""
let newMsg = Message.openai(
.assistant(
.init(
id: currentAssistantId,
content: "",
toolCalls: nil, audio: nil,
reasoning: ""
)))
self.chat?.messages.append(newMsg)
self.currentStreamingMessageId = currentAssistantId
}

currentAssistantReasoning += reasoning
if let index = self.chat?.messages.firstIndex(where: {
$0.id == self.currentStreamingMessageId
}) {
self.chat?.messages[index] = Message.openai(
.assistant(
.init(
id: currentAssistantId,
content: currentAssistantContent.isEmpty ? nil : currentAssistantContent,
toolCalls: nil, audio: nil,
reasoning: currentAssistantReasoning
)))
}

case .textDelta(let text):
if self.currentStreamingMessageId == nil {
currentAssistantId = UUID().uuidString
currentAssistantContent = ""
currentAssistantReasoning = ""
let newMsg = Message.openai(
.assistant(
.init(
Expand All @@ -282,7 +315,8 @@ public class ChatProvider: ChatProviderProtocol {
.init(
id: currentAssistantId,
content: currentAssistantContent,
toolCalls: nil, audio: nil
toolCalls: nil, audio: nil,
reasoning: currentAssistantReasoning.isEmpty ? nil : currentAssistantReasoning
)))
}

Expand Down Expand Up @@ -384,6 +418,7 @@ public class ChatProvider: ChatProviderProtocol {

var currentAssistantId = UUID().uuidString
var currentAssistantContent = ""
var currentAssistantReasoning = ""

let initialMsg = Message.openai(
.assistant(
Expand All @@ -404,10 +439,42 @@ public class ChatProvider: ChatProviderProtocol {
if Task.isCancelled { break }

switch part {
case .reasoningDelta(let reasoning):
if self.currentStreamingMessageId == nil {
currentAssistantId = UUID().uuidString
currentAssistantContent = ""
currentAssistantReasoning = ""
let newMsg = Message.openai(
.assistant(
.init(
id: currentAssistantId,
content: "",
toolCalls: nil, audio: nil,
reasoning: ""
)))
self.chat?.messages.append(newMsg)
self.currentStreamingMessageId = currentAssistantId
}

currentAssistantReasoning += reasoning
if let index = self.chat?.messages.firstIndex(where: {
$0.id == self.currentStreamingMessageId
}) {
self.chat?.messages[index] = Message.openai(
.assistant(
.init(
id: currentAssistantId,
content: currentAssistantContent.isEmpty ? nil : currentAssistantContent,
toolCalls: nil, audio: nil,
reasoning: currentAssistantReasoning
)))
}

case .textDelta(let text):
if self.currentStreamingMessageId == nil {
currentAssistantId = UUID().uuidString
currentAssistantContent = ""
currentAssistantReasoning = ""
let newMsg = Message.openai(
.assistant(
.init(
Expand All @@ -428,7 +495,8 @@ public class ChatProvider: ChatProviderProtocol {
.init(
id: currentAssistantId,
content: currentAssistantContent,
toolCalls: nil, audio: nil
toolCalls: nil, audio: nil,
reasoning: currentAssistantReasoning.isEmpty ? nil : currentAssistantReasoning
)))
}

Expand Down
170 changes: 170 additions & 0 deletions Sources/AgentLayout/Message/ThinkingContentView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
//
// ThinkingContentView.swift
// AgentLayout
//
// Created by Claude on 11/29/25.
//

import Agent
import MarkdownUI
import Shimmer
import Splash
import SwiftUI

/// An expandable view that displays the model's thinking/reasoning process.
/// Shows summary as a clickable title, and expands to show full reasoning content.
struct ThinkingContentView: View {
/// Summary text shown as the title (from reasoning.summary)
let summary: String?
/// Full reasoning content shown when expanded (from reasoningDelta)
let reasoning: String?
let status: ChatStatus

@State private var isExpanded = false

/// Title text to display (summary or fallback)
private var titleText: String {
if let summary = summary, !summary.isEmpty {
return summary
}
return "Thinking..."
}

/// Whether there's content to show when expanded
private var hasExpandableContent: Bool {
if let reasoning = reasoning, !reasoning.isEmpty {
return true
}
return false
}

var body: some View {
VStack(alignment: .leading, spacing: 8) {
Button(action: {
withAnimation {
isExpanded.toggle()
}
}) {
HStack(alignment: .top, spacing: 6) {
Image(systemName: "chevron.right")
.font(.system(size: 12))
.rotationEffect(.degrees(isExpanded ? 90 : 0))
.animation(.spring(), value: isExpanded)
.foregroundColor(.gray)

Image(systemName: "brain.head.profile")
.foregroundColor(.orange.mix(with: .mint, by: 0.9))

if status == .loading && summary == nil && (reasoning == nil || reasoning?.isEmpty == true) {
Text("Thinking...")
.lineLimit(1)
.foregroundColor(.orange.mix(with: .mint, by: 0.9))
.shimmering()
} else {
Markdown(titleText)
.markdownTheme(.chatTheme)
.lineLimit(1)
.foregroundColor(.orange.mix(with: .mint, by: 0.9))
}
}
.font(.system(size: 14))
}
.buttonStyle(.plain)
.frame(maxWidth: .infinity, alignment: .leading)

if isExpanded {
VStack(alignment: .leading, spacing: 8) {
if hasExpandableContent {
Markdown(reasoning!)
.markdownTheme(.chatTheme)
.markdownCodeSyntaxHighlighter(
SplashCodeSyntaxHighlighter(
theme: .wwdc18(withFont: .init(size: 14)))
)
.textSelection(.enabled)
.padding(12)
.background(Color.gray.opacity(0.1))
.cornerRadius(8)
.fixedSize(horizontal: false, vertical: true)
} else {
Text("No detailed reasoning available")
.font(.caption)
.foregroundColor(.secondary)
.padding(8)
}
}
.padding(.leading, 16)
.transition(.opacity)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.padding(.vertical, 4)
.frame(maxWidth: .infinity, alignment: .leading)
}
}

#Preview("With Summary and Reasoning") {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
ThinkingContentView(
summary: "The model analyzed the requirements for an ERC20 token implementation...",
reasoning: """
Let me think through this step by step:

1. First, I need to understand the user's question about Solidity programming.
2. The user wants to implement an ERC20 token, which is a standard interface for fungible tokens.
3. I should consider the key functions: `transfer`, `approve`, `transferFrom`, `balanceOf`, and `allowance`.
4. Security considerations are important - we need to prevent overflow/underflow attacks.

```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract SimpleToken {
mapping(address => uint256) private _balances;
uint256 private _totalSupply;
}
```
""",
status: .idle
)
}
.padding()
}
}

#Preview("Summary Only") {
ThinkingContentView(
summary: "The model considered multiple approaches before selecting the optimal solution.",
reasoning: nil,
status: .idle
)
.padding()
}

#Preview("Reasoning Only (No Summary)") {
ThinkingContentView(
summary: nil,
reasoning: "Let me analyze this problem step by step. First, I need to understand the requirements...",
status: .idle
)
.padding()
}

#Preview("Streaming - Loading") {
ThinkingContentView(
summary: nil,
reasoning: nil,
status: .loading
)
.padding()
}

#Preview("Streaming - With Summary") {
ThinkingContentView(
summary: "Analyzing the problem...",
reasoning: "Let me think about this...",
status: .loading
)
.padding()
}
Loading