Skip to content
Open
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
7 changes: 7 additions & 0 deletions Sources/Wax/Broker/MCPPrimeAssembly.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ package enum MCPPrimeAssembly {
package static let maxProjectItems = 5
package static let maxItemTokens = 160
package static let maxItemBytes = 2_048
/// Taskless handoffs shorter than this are teardown residue
/// (e.g. "transport idleExpiry"), not human-authored context.
package static let minHandoffContentChars = 32
package static let ownership = "injection"
package static let ownershipLevel = "B"

Expand Down Expand Up @@ -504,6 +507,9 @@ package enum MCPPrimeAssembly {
let leftRank = projectTypeRank[lhs.memoryType] ?? 99
let rightRank = projectTypeRank[rhs.memoryType] ?? 99
if leftRank != rightRank { return leftRank < rightRank }
// A stale hint means a newer same-type item is in this lane: rank the
// fresher item first, but keep stale items as backfill, never drop them.
if lhs.staleHint != rhs.staleHint { return !lhs.staleHint }
if lhs.score != rhs.score { return lhs.score > rhs.score }
if lhs.createdAtMs != rhs.createdAtMs { return lhs.createdAtMs > rhs.createdAtMs }
return lhs.text < rhs.text
Expand Down Expand Up @@ -539,6 +545,7 @@ package enum MCPPrimeAssembly {
|| tasks.count != handoff.pendingTasks.count
|| zip(handoff.pendingTasks.prefix(tasks.count), tasks).contains { $0 != $1 }
guard !compact.isEmpty || !tasks.isEmpty else { return nil }
if tasks.isEmpty, compact.count < minHandoffContentChars { return nil }
handoff.content = compact
handoff.pendingTasks = Array(tasks)
handoff.truncated = handoff.truncated || truncated
Expand Down
7 changes: 5 additions & 2 deletions Sources/WaxCLI/MCPCheckpointCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ struct MCPCheckpointOutcome: Sendable, Equatable {

enum MCPCheckpointRunner {
static let defaultTimeoutSeconds: TimeInterval = 1.5
static let defaultHandoffContent = "checkpoint"

struct Request: Sendable {
var sessionID: String?
Expand Down Expand Up @@ -79,7 +78,7 @@ enum MCPCheckpointRunner {
)
}
} else {
content = defaultHandoffContent
content = ""
}

if let rawID = request.sessionID?.trimmingCharacters(in: .whitespacesAndNewlines), !rawID.isEmpty {
Expand Down Expand Up @@ -248,12 +247,16 @@ enum MCPCheckpointRunner {
)
}

// No content file means no human-authored handoff: close the session
// without recording a stub (mirrors transport teardown).
let recordHandoff = request.contentFile != nil
let response = try probe(
AgentBrokerRequest(
command: "session_close",
arguments: [
"session_id": .string(sessionID.uuidString),
"content": .string(content),
"record_handoff": .bool(recordHandoff),
]
),
configuration,
Expand Down
4 changes: 3 additions & 1 deletion Sources/WaxCLI/MCPPrimeCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,9 @@ enum MCPPrimeRunner {
"query": .string(
"\(attribution.project ?? attribution.repo ?? "project") lessons facts decisions constraints"
),
"limit": .from(8),
// Pool deeper than the rendered slots: assembly drops
// secrets/mismatches and demotes stale hints.
"limit": .from(12),
"scope": .string("project"),
"cwd": .string(request.cwd),
"project": .from(attribution.project),
Expand Down
72 changes: 72 additions & 0 deletions Tests/WaxCLITests/MCPCheckpointTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,78 @@ struct MCPCheckpointTests {
#expect(captured.value.isEmpty == false)
#expect(outcome.stderr.isEmpty)
}

@Test func checkpointWithoutContentFileRecordsNoHandoff() throws {
let root = try makeRoot()
defer { try? FileManager.default.removeItem(at: root.url) }
let sessionID = UUID()
let capturedContent = LockBox<String?>(nil)
let capturedRecord = LockBox<Bool?>(nil)
let outcome = MCPCheckpointRunner.run(
MCPCheckpointRunner.Request(
sessionID: sessionID.uuidString,
host: nil,
conversationID: nil,
cwd: nil,
contentFile: nil,
strict: false,
timeoutSeconds: 1.5,
storePath: root.store.path,
noEmbedder: true,
embedderChoice: "minilm",
configuration: root.configuration,
probe: { request, _, _ in
capturedContent.value = request.arguments["content"]?.stringValue
capturedRecord.value = request.arguments["record_handoff"]?.boolValue
#expect(request.command == "session_close")
return AgentBrokerResponse.success(
payload: .object(["already_ended": .bool(false)])
)
},
sessionRootURL: root.sessions
)
)
#expect(outcome.exitCode == 0)
#expect(capturedContent.value == "")
#expect(capturedRecord.value == false)
}

@Test func checkpointWithContentFileRecordsAuthoredHandoff() throws {
let root = try makeRoot()
defer { try? FileManager.default.removeItem(at: root.url) }
let file = root.url.appendingPathComponent("handoff.txt")
try "ship the stale-prime fix".write(to: file, atomically: true, encoding: .utf8)
let sessionID = UUID()
let capturedContent = LockBox<String?>(nil)
let capturedRecord = LockBox<Bool?>(nil)
let outcome = MCPCheckpointRunner.run(
MCPCheckpointRunner.Request(
sessionID: sessionID.uuidString,
host: nil,
conversationID: nil,
cwd: nil,
contentFile: file.path,
strict: false,
timeoutSeconds: 1.5,
storePath: root.store.path,
noEmbedder: true,
embedderChoice: "minilm",
configuration: root.configuration,
probe: { request, _, _ in
capturedContent.value = request.arguments["content"]?.stringValue
capturedRecord.value = request.arguments["record_handoff"]?.boolValue
#expect(request.command == "session_close")
return AgentBrokerResponse.success(
payload: .object(["already_ended": .bool(false)])
)
},
sessionRootURL: root.sessions
)
)
#expect(outcome.exitCode == 0)
#expect(capturedContent.value == "ship the stale-prime fix")
#expect(capturedRecord.value != false)
}
}

private final class LockBox<Value>: @unchecked Sendable {
Expand Down
35 changes: 35 additions & 0 deletions Tests/WaxTests/MCPPrimeAssemblyTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,41 @@ func primeAssemblyAttachesProvenanceAndOmitsLifecyclePlaybookFields() throws {
#expect(envelope.renderedJSON.contains("session_id") == false)
}

@Test
func primeAssemblyDropsStubContentOnlyHandoff() {
let envelope = assemble(
project: [candidate("Keep hooks read-only.", type: "lesson")],
handoff: MCPPrimeAssembly.Handoff(content: "transport idleExpiry", project: "Wax")
)
#expect(envelope.handoff == nil)
#expect(envelope.hostContext.contains("[handoff]") == false)
}

@Test
func primeAssemblyKeepsStubHandoffWithPendingTasks() {
let envelope = assemble(
project: [candidate("Keep hooks read-only.", type: "lesson")],
handoff: MCPPrimeAssembly.Handoff(
content: "transport idleExpiry", project: "Wax", pendingTasks: ["land tests"]
)
)
#expect(envelope.handoff?.found == true)
#expect(envelope.hostContext.contains("[handoff]"))
}

@Test
func primeAssemblyKeepsSubstantiveTasklessHandoff() {
let envelope = assemble(
project: [candidate("Keep hooks read-only.", type: "lesson")],
handoff: MCPPrimeAssembly.Handoff(
content: "Shipped stale-demotion; remaining work is the handoff write-path fix.",
project: "Wax"
)
)
#expect(envelope.handoff?.found == true)
#expect(envelope.hostContext.contains("[handoff]"))
}

@Test(arguments: [
MCPPrimeAssembly.Format.claude,
.codex,
Expand Down
48 changes: 48 additions & 0 deletions Tests/WaxTests/StalenessSignalsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,54 @@ func primeStaleHintFlagsOlderSameTypeItem() throws {
#expect(envelope.hostContext.contains("stale"))
}

private func primeStalenessCandidate(
_ text: String,
type: String,
score: Double,
createdAtMs: Int64
) -> MCPPrimeAssembly.Candidate {
MCPPrimeAssembly.Candidate(
text: text, memoryType: type,
project: "Wax", repo: "Wax", score: score, createdAtMs: createdAtMs
)
}

private func primeStalenessOrder(_ candidates: [MCPPrimeAssembly.Candidate]) -> [String] {
MCPPrimeAssembly.assemble(
MCPPrimeAssembly.Input(
host: "claude",
includePerson: false,
projectMiss: false,
project: "Wax",
repo: "Wax",
personCandidates: [],
projectCandidates: candidates,
handoff: nil
),
tokenizer: .character,
nowMs: 10_000
).projectItems.map(\.text)
}

@Test
func primeStaleHintDemotesOlderSameTypeItem() {
let order = primeStalenessOrder([
primeStalenessCandidate("old decision", type: "decision", score: 0.9, createdAtMs: 1_000),
primeStalenessCandidate("new decision", type: "decision", score: 0.8, createdAtMs: 9_000),
])
#expect(order == ["new decision", "old decision"])
}

@Test
func primeStaleHintKeepsTypeRankAboveFreshness() {
let order = primeStalenessOrder([
primeStalenessCandidate("old constraint", type: "constraint", score: 0.9, createdAtMs: 1_000),
primeStalenessCandidate("new constraint", type: "constraint", score: 0.8, createdAtMs: 2_000),
primeStalenessCandidate("fresh fact", type: "fact", score: 1.0, createdAtMs: 3_000),
])
#expect(order == ["new constraint", "old constraint", "fresh fact"])
}

@Test
func reviewQueueFrameMatchesUnreviewedDecisionsAndConstraints() {
#expect(AgentBrokerService.isReviewQueueFrame([MemoryMetadataKeys.type: "decision"]) == true)
Expand Down
Loading