mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-08 19:19:42 +00:00
Group ordinary Codex commands with default tools across web, iOS and Android while preserving exploration and user-shell boundaries. Add regression tests, generated protocol fixtures and shared-command coverage in the iOS transcript UI suite.
412 lines
14 KiB
Swift
412 lines
14 KiB
Swift
import Foundation
|
|
|
|
// Port of web/src/chat/toolGroups.ts — collapsing runs of low-signal tool
|
|
// calls into tool-group blocks, plus the codex-exploration grouping family
|
|
// (which needs the CodexBash command_actions parsing from
|
|
// web/src/chat/codexCommandPresentation.ts, ported in ToolPresentation.swift).
|
|
|
|
// MARK: - Tool input access (web/src/lib/toolInputUtils.ts)
|
|
|
|
/// Port of `getInputString` — non-string values yield nil; the `Any` variant
|
|
/// additionally skips empty strings (JS truthiness).
|
|
func getInputString(_ input: JSONValue?, _ key: String) -> String? {
|
|
input?.objectValue?[key]?.stringValue
|
|
}
|
|
|
|
/// Port of `getInputStringAny`.
|
|
func getInputStringAny(_ input: JSONValue?, _ keys: [String]) -> String? {
|
|
for key in keys {
|
|
if let value = getInputString(input, key), !value.isEmpty {
|
|
return value
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// MARK: - Group model
|
|
|
|
public enum ToolGroupActionKind: String, Equatable, Sendable, CaseIterable {
|
|
case read
|
|
case search
|
|
case command
|
|
case mutation
|
|
case web
|
|
case other
|
|
}
|
|
|
|
/// Port of `ToolGroupSummary`.
|
|
public struct ToolGroupSummary: Equatable, Sendable {
|
|
public var totalTools: Int
|
|
public var countsByKind: [ToolGroupActionKind: Int]
|
|
public var fileTargets: [String]
|
|
public var commandTargets: [String]
|
|
public var searchTargets: [String]
|
|
public var urlTargets: [String]
|
|
public var otherTargets: [String]
|
|
public var errorCount: Int
|
|
public var runningCount: Int
|
|
public var pendingCount: Int
|
|
}
|
|
|
|
/// Port of `ToolGroupBlock`.
|
|
public struct ToolGroupBlock: Equatable, Sendable {
|
|
public var id: String
|
|
public var createdAt: Int
|
|
public var invokedAt: Int?
|
|
public var firstToolId: String
|
|
public var lastToolId: String
|
|
public var tools: [ToolCallBlock]
|
|
public var defaultOpen: Bool
|
|
public var historyState: HistoryState
|
|
public var needsOlderHistory: Bool
|
|
public var activityTitle: String?
|
|
public var presentationMode: PresentationMode
|
|
public var summary: ToolGroupSummary
|
|
|
|
public enum HistoryState: String, Equatable, Sendable {
|
|
case complete
|
|
case needsOlderHistory = "needs-older-history"
|
|
}
|
|
|
|
public enum PresentationMode: String, Equatable, Sendable {
|
|
case `default`
|
|
case codexExploration = "codex-exploration"
|
|
}
|
|
}
|
|
|
|
/// Port of `VisibleChatBlock`.
|
|
public enum VisibleChatBlock: Equatable, Sendable {
|
|
case block(ChatBlock)
|
|
case toolGroup(ToolGroupBlock)
|
|
}
|
|
|
|
public enum VisibleChatBlockRole: String, Equatable, Sendable {
|
|
case user
|
|
case assistant
|
|
case system
|
|
}
|
|
|
|
/// Port of `visibleBlockRole`.
|
|
public func visibleBlockRole(_ block: VisibleChatBlock) -> VisibleChatBlockRole {
|
|
switch block {
|
|
case .toolGroup:
|
|
return .assistant
|
|
case .block(let chatBlock):
|
|
switch chatBlock {
|
|
case .userText: return .user
|
|
case .agentEvent: return .system
|
|
case .cliOutput(let cliOutput): return cliOutput.source == .user ? .user : .assistant
|
|
default: return .assistant
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Port of `ToolGroupingOptions`.
|
|
public struct ToolGroupingOptions: Sendable {
|
|
public var hasMoreMessages: Bool
|
|
public var previousGroups: [ToolGroupBlock]
|
|
/// Tri-state like the TS optional boolean: only an explicit `false`
|
|
/// opens codex-exploration groups by default.
|
|
public var codexExplorationCollapsed: Bool?
|
|
|
|
public init(hasMoreMessages: Bool, previousGroups: [ToolGroupBlock] = [], codexExplorationCollapsed: Bool? = nil) {
|
|
self.hasMoreMessages = hasMoreMessages
|
|
self.previousGroups = previousGroups
|
|
self.codexExplorationCollapsed = codexExplorationCollapsed
|
|
}
|
|
}
|
|
|
|
// MARK: - Name tables
|
|
|
|
private let planToolNames: Set<String> = [
|
|
"TodoWrite",
|
|
"update_plan",
|
|
"ExitPlanMode",
|
|
"exit_plan_mode",
|
|
"CodexReasoning",
|
|
]
|
|
|
|
private let milestoneToolNames: Set<String> = [
|
|
"Task",
|
|
"Agent",
|
|
"CodexAgent",
|
|
"TeamCreate",
|
|
"TeamDelete",
|
|
"SendMessage",
|
|
"AgyTaskLog",
|
|
"Skill",
|
|
"spawn_agent",
|
|
"send_input",
|
|
"send_message",
|
|
"resume_agent",
|
|
"followup_task",
|
|
"wait_agent",
|
|
"close_agent",
|
|
"interrupt_agent",
|
|
"list_agents",
|
|
]
|
|
|
|
private let interactiveToolNames: Set<String> = [
|
|
"CodexPermission",
|
|
]
|
|
|
|
/// Port of `isAskUserQuestionToolName` (web ToolCard/askUserQuestion.ts).
|
|
public func isAskUserQuestionToolName(_ toolName: String) -> Bool {
|
|
toolName == "AskUserQuestion" || toolName == "ask_user_question" || toolName == "CursorAskQuestion"
|
|
}
|
|
|
|
/// Port of `isRequestUserInputToolName` (web ToolCard/requestUserInput.ts).
|
|
public func isRequestUserInputToolName(_ toolName: String) -> Bool {
|
|
toolName == "request_user_input"
|
|
}
|
|
|
|
// MARK: - Summaries
|
|
|
|
private func pushUnique(_ target: inout [String], _ value: String?) {
|
|
guard let value, !value.isEmpty else { return }
|
|
if target.contains(value) { return }
|
|
target.append(value)
|
|
}
|
|
|
|
/// Port of `normalizeCommandInput`.
|
|
private func normalizeCommandInput(_ input: JSONValue?) -> String? {
|
|
if let direct = getInputStringAny(input, ["command", "cmd"]) { return direct }
|
|
|
|
guard let command = input?.objectValue?["command"]?.arrayValue else { return nil }
|
|
let parts = command.compactMap { part -> String? in
|
|
guard let text = part.stringValue, !text.isEmpty else { return nil }
|
|
return text
|
|
}
|
|
return parts.isEmpty ? nil : parts.joined(separator: " ")
|
|
}
|
|
|
|
/// Port of `getToolGroupActionKind`.
|
|
public func getToolGroupActionKind(_ block: ToolCallBlock) -> ToolGroupActionKind {
|
|
let name = block.tool.name
|
|
|
|
if name == "Read" || name == "NotebookRead" { return .read }
|
|
if name == "Grep" || name == "Glob" || name == "LS" { return .search }
|
|
if name == "Bash" || name == "CodexBash" || name == "shell_command" || name == "run_shell_command" { return .command }
|
|
if name == "Edit" || name == "MultiEdit" || name == "Write" || name == "NotebookEdit"
|
|
|| name == "CodexPatch" || name == "CodexDiff" {
|
|
return .mutation
|
|
}
|
|
if name == "WebFetch" || name == "WebSearch" { return .web }
|
|
return .other
|
|
}
|
|
|
|
private func getPrimaryFileTarget(_ block: ToolCallBlock) -> String? {
|
|
getInputStringAny(block.tool.input, ["file_path", "path", "file", "filePath", "notebook_path", "name"])
|
|
}
|
|
|
|
private func getPrimarySearchTarget(_ block: ToolCallBlock) -> String? {
|
|
getInputStringAny(block.tool.input, ["pattern", "query"])
|
|
}
|
|
|
|
private func getPrimaryUrlTarget(_ block: ToolCallBlock) -> String? {
|
|
getInputStringAny(block.tool.input, ["url"])
|
|
}
|
|
|
|
private func getPrimaryOtherTarget(_ block: ToolCallBlock) -> String? {
|
|
if let fileTarget = getPrimaryFileTarget(block) { return fileTarget }
|
|
if let searchTarget = getPrimarySearchTarget(block) { return searchTarget }
|
|
if let commandTarget = normalizeCommandInput(block.tool.input) { return commandTarget }
|
|
if let urlTarget = getPrimaryUrlTarget(block) { return urlTarget }
|
|
return block.tool.name
|
|
}
|
|
|
|
/// Port of `summarizeToolGroup`.
|
|
private func summarizeToolGroup(_ tools: [ToolCallBlock]) -> ToolGroupSummary {
|
|
var countsByKind: [ToolGroupActionKind: Int] = [
|
|
.read: 0, .search: 0, .command: 0, .mutation: 0, .web: 0, .other: 0,
|
|
]
|
|
var fileTargets: [String] = []
|
|
var commandTargets: [String] = []
|
|
var searchTargets: [String] = []
|
|
var urlTargets: [String] = []
|
|
var otherTargets: [String] = []
|
|
var errorCount = 0
|
|
var runningCount = 0
|
|
var pendingCount = 0
|
|
|
|
for tool in tools {
|
|
let kind = getToolGroupActionKind(tool)
|
|
countsByKind[kind, default: 0] += 1
|
|
|
|
switch tool.tool.state {
|
|
case .error: errorCount += 1
|
|
case .running: runningCount += 1
|
|
case .pending: pendingCount += 1
|
|
case .completed: break
|
|
}
|
|
|
|
if kind == .read || kind == .mutation {
|
|
pushUnique(&fileTargets, getPrimaryFileTarget(tool))
|
|
continue
|
|
}
|
|
if kind == .search {
|
|
pushUnique(&searchTargets, getPrimarySearchTarget(tool))
|
|
continue
|
|
}
|
|
if kind == .command {
|
|
pushUnique(&commandTargets, normalizeCommandInput(tool.tool.input))
|
|
continue
|
|
}
|
|
if kind == .web {
|
|
pushUnique(&urlTargets, getPrimaryUrlTarget(tool) ?? getPrimarySearchTarget(tool))
|
|
continue
|
|
}
|
|
pushUnique(&otherTargets, getPrimaryOtherTarget(tool))
|
|
}
|
|
|
|
return ToolGroupSummary(
|
|
totalTools: tools.count,
|
|
countsByKind: countsByKind,
|
|
fileTargets: fileTargets,
|
|
commandTargets: commandTargets,
|
|
searchTargets: searchTargets,
|
|
urlTargets: urlTargets,
|
|
otherTargets: otherTargets,
|
|
errorCount: errorCount,
|
|
runningCount: runningCount,
|
|
pendingCount: pendingCount
|
|
)
|
|
}
|
|
|
|
// MARK: - Eligibility
|
|
|
|
private func isInteractiveToolBlock(_ block: ToolCallBlock) -> Bool {
|
|
interactiveToolNames.contains(block.tool.name)
|
|
|| block.tool.permission?.status == .pending
|
|
|| isAskUserQuestionToolName(block.tool.name)
|
|
|| isRequestUserInputToolName(block.tool.name)
|
|
}
|
|
|
|
/// Port of `isEligibleForToolGrouping`.
|
|
public func isEligibleForToolGrouping(_ block: ToolCallBlock) -> Bool {
|
|
if isSubagentToolName(block.tool.name) { return false }
|
|
if planToolNames.contains(block.tool.name) { return false }
|
|
if milestoneToolNames.contains(block.tool.name) { return false }
|
|
if isInteractiveToolBlock(block) { return false }
|
|
// Classification selects the group family; shared Codex `unknown`
|
|
// agent commands still belong in ordinary groups. User shell stays separate.
|
|
if block.tool.name == "CodexBash"
|
|
&& getInputStringAny(block.tool.input, ["command_source", "commandSource"])?.lowercased() == "usershell" {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
private func getGroupingFamily(_ block: ToolCallBlock) -> ToolGroupBlock.PresentationMode? {
|
|
guard isEligibleForToolGrouping(block) else { return nil }
|
|
return isCodexExplorationTool(block) ? .codexExploration : .default
|
|
}
|
|
|
|
/// Boundary-based identity reuse, with native list uniqueness guarantees:
|
|
/// splits may claim an old ID only once; fresh runs cannot steal a later
|
|
/// group's historical ID. No row-index or random IDs (scroll-anchor stability).
|
|
private func createToolGroupId(
|
|
tools: [ToolCallBlock],
|
|
needsOlderHistory: Bool,
|
|
previousGroups: [ToolGroupBlock],
|
|
reservedGroupIds: Set<String>,
|
|
usedIds: inout Set<String>
|
|
) -> String {
|
|
let firstToolId = tools.first?.id ?? "unknown"
|
|
let lastToolId = tools.last?.id ?? firstToolId
|
|
|
|
if let previous = previousGroups.first(where: {
|
|
($0.firstToolId == firstToolId || $0.lastToolId == lastToolId) && !usedIds.contains($0.id)
|
|
}) {
|
|
usedIds.insert(previous.id)
|
|
return previous.id
|
|
}
|
|
|
|
let boundaries = needsOlderHistory ? [lastToolId, firstToolId] : [firstToolId, lastToolId]
|
|
for boundary in boundaries {
|
|
let candidate = "tool-group:\(boundary)"
|
|
if !reservedGroupIds.contains(candidate), usedIds.insert(candidate).inserted { return candidate }
|
|
}
|
|
let base = "tool-group:\(boundaries[0])"
|
|
var suffix = 2
|
|
var candidate = "\(base)#\(suffix)"
|
|
while reservedGroupIds.contains(candidate) || usedIds.contains(candidate) {
|
|
suffix += 1
|
|
candidate = "\(base)#\(suffix)"
|
|
}
|
|
usedIds.insert(candidate)
|
|
return candidate
|
|
}
|
|
|
|
// MARK: - buildVisibleChatBlocks
|
|
|
|
/// Port of `buildVisibleChatBlocks`.
|
|
public func buildVisibleChatBlocks(
|
|
_ blocks: [ChatBlock],
|
|
options: ToolGroupingOptions
|
|
) -> [VisibleChatBlock] {
|
|
var visibleBlocks: [VisibleChatBlock] = []
|
|
let previousGroups = options.previousGroups
|
|
let reservedGroupIds = Set(previousGroups.map(\.id))
|
|
// Includes flattened tool children as well as ordinary display blocks.
|
|
var usedIds = Set(blocks.map(\.id))
|
|
|
|
var index = 0
|
|
while index < blocks.count {
|
|
let block = blocks[index]
|
|
guard case .toolCall(let toolBlock) = block else {
|
|
visibleBlocks.append(.block(block))
|
|
index += 1
|
|
continue
|
|
}
|
|
guard let groupingFamily = getGroupingFamily(toolBlock) else {
|
|
visibleBlocks.append(.block(block))
|
|
index += 1
|
|
continue
|
|
}
|
|
|
|
var tools: [ToolCallBlock] = [toolBlock]
|
|
var cursor = index + 1
|
|
while cursor < blocks.count {
|
|
guard case .toolCall(let candidate) = blocks[cursor],
|
|
getGroupingFamily(candidate) == groupingFamily else {
|
|
break
|
|
}
|
|
tools.append(candidate)
|
|
cursor += 1
|
|
}
|
|
|
|
if tools.count < 2 && groupingFamily != .codexExploration {
|
|
visibleBlocks.append(.block(block))
|
|
index += 1
|
|
continue
|
|
}
|
|
|
|
let startsAtOldestVisibleBoundary = visibleBlocks.isEmpty
|
|
let needsOlderHistory = options.hasMoreMessages && startsAtOldestVisibleBoundary
|
|
let activityTitle: String? = {
|
|
guard case .block(.toolCall(let previousBlock))? = visibleBlocks.last,
|
|
previousBlock.tool.name == "CodexReasoning" else { return nil }
|
|
return getInputStringAny(previousBlock.tool.input, ["title"])
|
|
}()
|
|
visibleBlocks.append(.toolGroup(ToolGroupBlock(
|
|
id: createToolGroupId(tools: tools, needsOlderHistory: needsOlderHistory,
|
|
previousGroups: previousGroups, reservedGroupIds: reservedGroupIds, usedIds: &usedIds),
|
|
createdAt: tools[0].createdAt,
|
|
invokedAt: tools[0].invokedAt,
|
|
firstToolId: tools[0].id,
|
|
lastToolId: tools[tools.count - 1].id,
|
|
tools: tools,
|
|
defaultOpen: groupingFamily == .codexExploration && options.codexExplorationCollapsed == false,
|
|
historyState: needsOlderHistory ? .needsOlderHistory : .complete,
|
|
needsOlderHistory: needsOlderHistory,
|
|
activityTitle: activityTitle,
|
|
presentationMode: groupingFamily,
|
|
summary: summarizeToolGroup(tools)
|
|
)))
|
|
index = cursor
|
|
}
|
|
|
|
return visibleBlocks
|
|
}
|