Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ DerivedData/
.swiftpm/xcode/xcuserdata/
.netrc
*.profraw
/dependencies
10 changes: 1 addition & 9 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 13 additions & 3 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,19 @@
// swift-tools-version: 6.3

import Foundation
import PackageDescription

let packageDirectory = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
let localCodexKitPath = packageDirectory
.appendingPathComponent("dependencies/CodexKit", isDirectory: true)
.path
let codexKitFallbackRevision = "58c2dab605c3dad806e0ac90c6d3d1f67c5fd36d"
let codexKitDependency: Package.Dependency =
FileManager.default.fileExists(atPath: "\(localCodexKitPath)/Package.swift")
? .package(path: localCodexKitPath)
: .package(url: "https://github.com/lynnswap/CodexKit.git", revision: codexKitFallbackRevision)

let package = Package(
name: "CodexReviewKit",
platforms: [
Expand Down Expand Up @@ -33,9 +45,7 @@ let package = Package(
.package(url: "https://github.com/modelcontextprotocol/swift-sdk.git", exact: "0.12.1"),
.package(url: "https://github.com/apple/swift-nio.git", from: "2.97.1"),
.package(url: "https://github.com/lynnswap/ObservationBridge.git", .upToNextMinor(from: "0.12.0")),
// CodexKit has no tagged releases yet; pin the reviewed native-login
// and final review response contract revision.
.package(url: "https://github.com/lynnswap/CodexKit.git", revision: "d2a694d7f633c1f01d4d260585863c0a3eae21af"),
codexKitDependency,
],
targets: [
.target(
Expand Down
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,28 @@ This Claude Code setting is process-wide. It is not scoped to the
`codex_review` MCP server, so the same idle timeout applies to all MCP tools
used by that Claude Code session.

## Local CodexKit Development

`Package.swift` uses a local `dependencies/CodexKit` checkout when that
directory contains a `Package.swift`. If the local checkout is absent, SwiftPM
resolves `CodexKit` from the pinned fallback revision in `Package.swift`.
Update that revision to a reviewed CodexKit `main` commit whenever
CodexReviewKit adopts new CodexKit APIs.

```bash
mkdir -p dependencies
git clone git@github.com:lynnswap/CodexKit.git dependencies/CodexKit
swift test --build-system swiftbuild --no-parallel
```

After creating or removing `dependencies/CodexKit`, run SwiftPM with manifest
caching disabled once if resolution still points at the previous dependency
kind:

```bash
swift package --manifest-cache none resolve
```

## More Detail

- [Architecture](Docs/architecture.md): package boundaries, runtime flow, and
Expand Down
16 changes: 11 additions & 5 deletions Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,8 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend, CodexModelActor {
in: workspace,
input: CodexReviewInput(
target: request.request.target.appServerReviewTarget,
options: reviewThreadOptions(request)
options: reviewThreadOptions(request),
delivery: .inline
Comment thread
lynnswap marked this conversation as resolved.
)
)
.session
Expand Down Expand Up @@ -237,6 +238,7 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend, CodexModelActor {
let review = try await appServer.restartPreparedReview(
appServerToken,
target: request.request.target.appServerReviewTarget,
delivery: .inline,
threadOptions: reviewThreadOptions(model: interruptedRun.model ?? request.model)
)
let attemptID = makeAppServerReviewAttemptID()
Expand Down Expand Up @@ -597,16 +599,20 @@ private enum AppServerTypedReviewEventAdapter {
message: response.status?.rawValue ?? "Failed."
)
}
guard let finalReview = response.finalAnswer?.nilIfEmpty
?? response.transcript.finalAnswer?.nilIfEmpty
else {
return [.failed("Review completed without a final response.")]
guard let finalReview = reviewCompletionText(for: response) else {
return [.failed("Review completed without review output.")]
}
return [
.completed(finalReview: finalReview)
]
}

private static func reviewCompletionText(for response: CodexResponse) -> String? {
response.transcript.reviewOutputText?.nilIfEmpty
?? response.finalAnswer?.nilIfEmpty
?? response.transcript.finalAnswer?.nilIfEmpty
}

private static func unknownStatusEvents(
_: String,
turnID _: String
Expand Down
6 changes: 3 additions & 3 deletions Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -685,7 +685,7 @@ extension CodexReviewStore {
if completePendingCancellationIfNeeded(for: runRecord) {
return recoveryState.currentRun
}
markReviewFailed(runRecord, message: "Review completed without a final response.")
markReviewFailed(runRecord, message: "Review completed without review output.")
}
return recoveryState.currentRun
}
Expand All @@ -706,7 +706,7 @@ extension CodexReviewStore {
if completePendingCancellationIfNeeded(for: runRecord) {
return true
}
markReviewFailed(runRecord, message: "Review completed without a final response.")
markReviewFailed(runRecord, message: "Review completed without review output.")
}
return true
}
Expand Down Expand Up @@ -858,7 +858,7 @@ extension CodexReviewStore {
return
}
guard let finalReview = finalReview?.nilIfEmpty else {
markReviewFailed(runRecord, message: "Review completed without a final response.")
markReviewFailed(runRecord, message: "Review completed without review output.")
return
}
let endedAt = clock.now()
Expand Down
11 changes: 2 additions & 9 deletions Sources/CodexReviewMCPServer/CodexReviewMCPToolRequests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ func toolRequest(
limit: arguments["limit"]?.intValue
)
case .reviewCancel:
let runID = optionalRunID(in: arguments)
let runID = try ReviewRunIDArgument.optionalValue(in: arguments)
let sessionID = sessionID(
in: arguments,
defaultSessionID: defaultSessionID,
Expand All @@ -63,15 +63,8 @@ func sessionID(
defaultSessionID ?? arguments["sessionID"]?.stringValue ?? fallback
}

func optionalRunID(in arguments: [String: Value]) -> String? {
arguments["runID"]?.stringValue?.nilIfEmpty ?? arguments["runId"]?.stringValue?.nilIfEmpty
}

func requiredRunID(in arguments: [String: Value]) throws -> String {
guard let runID = optionalRunID(in: arguments) else {
throw MCPProtocolServerError.missingArgument("runID/runId")
}
return runID
try ReviewRunIDArgument.requiredValue(in: arguments)
}

func reviewTarget(from object: [String: Value]) throws -> CodexReviewAPI.Target {
Expand Down
23 changes: 18 additions & 5 deletions Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ private extension CodexReviewAPI.Read.Result {

func structuredContentForStartOrAwait(log: ReviewMCPLogProjection) -> Value {
structuredContent(
includeLog: true,
includeDetails: false,
includeNextAction: core.lifecycle.status.isTerminal == false,
log: log
Expand All @@ -71,13 +72,15 @@ private extension CodexReviewAPI.Read.Result {

func structuredContentForRead(log: ReviewMCPLogProjection) -> Value {
structuredContent(
includeLog: true,
includeDetails: true,
includeNextAction: false,
log: log
)
}

func structuredContent(
includeLog: Bool,
includeDetails: Bool,
includeNextAction: Bool,
log: ReviewMCPLogProjection
Expand All @@ -93,10 +96,12 @@ private extension CodexReviewAPI.Read.Result {
resolvedFinalReview: log.finalResult?.nilIfEmpty ?? core.finalReview
),
]
object["log"] =
includeDetails
? log.structuredContentWithItems()
: log.structuredContent()
if includeLog {
object["log"] =
includeDetails
? log.structuredContentWithItems()
: log.structuredContent()
}
if includeNextAction {
object["nextAction"] = .object([
"tool": .string(CodexReviewMCP.Tool.Name.reviewAwait.rawValue),
Expand All @@ -110,6 +115,14 @@ private extension CodexReviewAPI.Read.Result {
private extension ReviewMCPLogProjection {
func structuredContent() -> Value {
var truncatedFields: [String] = []
let orderedEntryIDs = Array(self.orderedEntryIDs.suffix(Self.compactEntryIDLimit))
let activeEntryIDs = Array(self.activeEntryIDs.suffix(Self.compactEntryIDLimit))
if orderedEntryIDs.count < self.orderedEntryIDs.count {
truncatedFields.append("orderedEntryIds")
}
if activeEntryIDs.count < self.activeEntryIDs.count {
truncatedFields.append("activeEntryIds")
}
var object: [String: Value] = [
"revision": .string(revision),
"orderedEntryIds": .array(orderedEntryIDs.map(Value.string)),
Expand Down Expand Up @@ -184,6 +197,7 @@ private extension ReviewMCPLogProjection {
return .object(object)
}

private static var compactEntryIDLimit: Int { 100 }
private static var detailedItemsLimit: Int { 100 }
}

Expand Down Expand Up @@ -467,7 +481,6 @@ private extension ParsedReviewResult.Finding {
"body": .string(body),
"priority": priority.map(Value.int) ?? .null,
"location": location.map { $0.structuredContent() } ?? .null,
"rawText": .string(rawText),
])
}
}
Expand Down
36 changes: 16 additions & 20 deletions Sources/CodexReviewMCPServer/CodexReviewMCPToolSchemas.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,28 +26,18 @@ func schema(for tool: CodexReviewMCP.Tool.Name) -> Value {
case .reviewAwait:
.object([
"type": .string("object"),
"properties": .object([
"properties": .object(reviewRunIDProperties(extra: [
"sessionID": .object(["type": .string("string")]),
"runID": .object(["type": .string("string")]),
"runId": .object(["type": .string("string")]),
]),
"anyOf": .array([
.object(["required": .array([.string("runId")])]),
.object(["required": .array([.string("runID")])]),
]),
])),
"anyOf": ReviewRunIDArgument.requiredAnyOf(),
])
case .reviewRead:
.object([
"type": .string("object"),
"properties": .object([
"properties": .object(reviewRunIDProperties(extra: [
"sessionID": .object(["type": .string("string")]),
"runID": .object(["type": .string("string")]),
"runId": .object(["type": .string("string")]),
]),
"anyOf": .array([
.object(["required": .array([.string("runId")])]),
.object(["required": .array([.string("runID")])]),
]),
])),
"anyOf": ReviewRunIDArgument.requiredAnyOf(),
])
case .reviewList:
.object([
Expand All @@ -62,14 +52,20 @@ func schema(for tool: CodexReviewMCP.Tool.Name) -> Value {
case .reviewCancel:
.object([
"type": .string("object"),
"properties": .object([
"properties": .object(reviewRunIDProperties(extra: [
"sessionID": .object(["type": .string("string")]),
"runID": .object(["type": .string("string")]),
"runId": .object(["type": .string("string")]),
"cwd": .object(["type": .string("string")]),
"statuses": .object(["type": .string("array")]),
"reason": .object(["type": .string("string")]),
]),
])),
])
}
}

private func reviewRunIDProperties(extra: [String: Value]) -> [String: Value] {
var properties = ReviewRunIDArgument.properties()
for (name, schema) in extra {
properties[name] = schema
}
return properties
}
2 changes: 1 addition & 1 deletion Sources/CodexReviewMCPServer/ReviewMCPLogProjection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ package struct ReviewMCPLogProjection: Sendable, Equatable {
self.finalLifecycleMessage = status.isTerminal ? lifecycleMessage : nil
self.finalResult =
status == .succeeded
? projectedItems.lastAssistantMessageText
? result.core.finalReview ?? projectedItems.lastAssistantMessageText
: nil
}
}
Expand Down
44 changes: 44 additions & 0 deletions Sources/CodexReviewMCPServer/ReviewRunIDArgument.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import CodexReviewKit
import MCP

enum ReviewRunIDArgument {
static let acceptedNames = ["runId", "runID", "jobId", "jobID"]
static let requiredDescription = acceptedNames.joined(separator: "/")

static func properties() -> [String: Value] {
Dictionary(uniqueKeysWithValues: acceptedNames.map { name in
(name, Value.object(["type": .string("string")]))
})
}

static func requiredAnyOf() -> Value {
.array(acceptedNames.map { name in
.object(["required": .array([.string(name)])])
})
}

static func optionalValue(in arguments: [String: Value]) throws -> String? {
let provided = acceptedNames.compactMap { name -> (name: String, value: String)? in
guard let value = arguments[name]?.stringValue?.nilIfEmpty else {
return nil
}
return (name, value)
}
guard let first = provided.first else {
return nil
}
if let conflict = provided.first(where: { $0.value != first.value }) {
throw MCPProtocolServerError.invalidArgument(
"Conflicting run identifier arguments: \(first.name) and \(conflict.name)."
)
}
return first.value
}

static func requiredValue(in arguments: [String: Value]) throws -> String {
guard let runID = try optionalValue(in: arguments) else {
throw MCPProtocolServerError.missingArgument(requiredDescription)
}
return runID
}
}
Loading