feat: 追踪 AI 自己答应的事,并能推回对应 session
用户一眼看出的缺口:清单里全是他说过的话,**AI 自己列的 todo 一条都没有**。 我先前的判断是"Claude 的 todo 不能当基线"——这没错,翻译那一步就已经丢 东西了。但我顺手把它整个排除了,而「它自己列了 6 项做完 3 项就说完事了」 恰恰是遗漏最直接的证据,且这类承诺不在用户原话里,别处根本抓不到。 现在收,但单独标成「AI 计划」,Origin.isFromUser 把"用户的权威基线"和 "待核对的 AI 自述"在类型层面分开 —— 混成一档就等于把自述当成了基线。 - TaskStateReader.readTasks 逐条读 ~/.claude/tasks/(零成本,不调模型)。 原来只给计数,而验收要的是"没做的那几项分别是什么"。 - Claude 自己勾掉的跟着同步成已确认(否则永远挂在未验收,用户手动清不动, 清不动的清单最后没人看)。只动 .assistantTask 那一档:它勾掉自己的 todo 不代表用户提的需求做完了,同名时尤其危险("发布新版本")。 - 按**会话**统计而不是按项目:用户问的是"这一轮它答应的做全了没"。 - 「告知这个会话」走 TerminalReply.send 把未做项注入回那个终端 —— 不是发通知让用户自己切过去,是直接把话说到那个 Claude 面前。 实测本机某会话读出 6 项完成 + 1 项 pending「真机验证」—— 正是典型的遗漏形态:活干完了,验证挂着,而它已经说"完成了"。 401 个测试。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
f1cdcd2547
commit
920ad7505f
6 files changed
+421
No files matched your search
@@ -59,6 +59,15 @@ public struct AcceptanceItem: Codable, Sendable, Identifiable, Equatable {
|
||||
case inferred
|
||||
/// 用户手敲的。
|
||||
case manual
|
||||
/// **Claude 自己列的 todo。**
|
||||
///
|
||||
/// 它不能当基线 —— 用户需求翻译成它的那一步就已经丢东西了,
|
||||
/// 这正是整个功能存在的理由。但把它整个排除也是错的:
|
||||
/// 「它自己列了 6 项,做完 3 项就说完事了」是遗漏最直接的证据,
|
||||
/// 而且这类承诺**不在用户的原话里,别处根本抓不到**。
|
||||
///
|
||||
/// 所以它进清单,但单独标一档,和用户原话那些分开看。
|
||||
case assistantTask
|
||||
|
||||
public var label: String {
|
||||
switch self {
|
||||
@@ -66,6 +75,15 @@ public struct AcceptanceItem: Codable, Sendable, Identifiable, Equatable {
|
||||
case .plan: return "计划"
|
||||
case .inferred: return "推导"
|
||||
case .manual: return "手敲"
|
||||
case .assistantTask: return "AI 计划"
|
||||
}
|
||||
}
|
||||
|
||||
/// 来自用户的(权威基线)还是来自 AI 的(待核对的自述)。
|
||||
public var isFromUser: Bool {
|
||||
switch self {
|
||||
case .userPrompt, .plan, .manual: return true
|
||||
case .inferred, .assistantTask: return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,23 @@ public struct TaskSnapshot: Sendable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Claude 自己列的一条任务。
|
||||
///
|
||||
/// 和 `TaskSnapshot` 的区别:那个只给计数,这个给逐条内容。
|
||||
/// 验收清单要的是后者 —— 「它列了 6 项只做了 3 项」这句话里,
|
||||
/// 值钱的是**没做的那 3 项分别是什么**,光知道数量没法验收。
|
||||
public struct ClaudeTask: Sendable, Equatable {
|
||||
public let order: Int
|
||||
public let subject: String
|
||||
public let done: Bool
|
||||
|
||||
public init(order: Int, subject: String, done: Bool) {
|
||||
self.order = order
|
||||
self.subject = subject
|
||||
self.done = done
|
||||
}
|
||||
}
|
||||
|
||||
/// 读 `~/.claude/tasks/<会话目录>/<id>.json` —— Claude Code 自己维护的任务清单。
|
||||
///
|
||||
/// 这是"这个会话还有后续没做完"的**确定性**判据,不用 AI、不花额度。
|
||||
@@ -66,6 +83,53 @@ public struct TaskStateReader: Sendable {
|
||||
return result
|
||||
}
|
||||
|
||||
/// 逐条读出 Claude 自己列的任务。
|
||||
///
|
||||
/// ## 为什么验收清单要收它
|
||||
///
|
||||
/// 这份清单**不能当基线**——用户的需求翻译成它的那一步就已经丢东西了,
|
||||
/// 这正是整个功能存在的理由。但把它整个排除掉也是错的:
|
||||
/// 「它自己列了 6 项,做完 3 项就说完事了」是遗漏最直接的证据,
|
||||
/// 而且这类承诺不在用户的原话里,别处根本抓不到。
|
||||
///
|
||||
/// 所以它进清单,但**单独标成「AI 计划」**,和用户原话那一档分开显示。
|
||||
///
|
||||
/// 零成本:读的是本地 JSON,不调模型。
|
||||
public func readTasks(sessionId: String) -> [ClaudeTask] {
|
||||
let fm = FileManager.default
|
||||
guard let dir = candidateDirectories(for: sessionId).first(where: {
|
||||
var isDir: ObjCBool = false
|
||||
return fm.fileExists(atPath: $0.path, isDirectory: &isDir) && isDir.boolValue
|
||||
}) else { return [] }
|
||||
|
||||
guard let entries = try? fm.contentsOfDirectory(
|
||||
at: dir, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles]
|
||||
) else { return [] }
|
||||
|
||||
var tasks: [ClaudeTask] = []
|
||||
for file in entries where file.pathExtension == "json" {
|
||||
// 同 read():单个文件坏掉跳过,别让整个目录失效。
|
||||
guard let data = try? Data(contentsOf: file),
|
||||
let object = try? JSONSerialization.jsonObject(with: data),
|
||||
let task = object as? [String: Any],
|
||||
let status = task["status"] as? String,
|
||||
let subject = (task["subject"] as? String)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!subject.isEmpty
|
||||
else { continue }
|
||||
|
||||
tasks.append(
|
||||
ClaudeTask(
|
||||
// 文件名是 "7.json"、"10.json",按数值排才是真实顺序。
|
||||
order: Int(file.deletingPathExtension().lastPathComponent) ?? Int.max,
|
||||
subject: subject,
|
||||
done: status == "completed"
|
||||
)
|
||||
)
|
||||
}
|
||||
return tasks.sorted { $0.order < $1.order }
|
||||
}
|
||||
|
||||
public func read(sessionId: String) -> TaskSnapshot? {
|
||||
let fm = FileManager.default
|
||||
guard let dir = candidateDirectories(for: sessionId).first(where: {
|
||||
|
||||
@@ -29,6 +29,11 @@ struct AcceptancePane: View {
|
||||
/// 正在跑验证的那一条。同时只允许跑一条 —— 并发跑 `swift build`
|
||||
/// 会互相抢构建目录锁,结果全是假失败。
|
||||
@State private var running: String?
|
||||
/// 正在往哪个会话推消息。防连点 —— 注入是往终端敲字,重复注入会串行叠加。
|
||||
@State private var notifying: String?
|
||||
|
||||
/// 往终端注入文字的通道。和岛上「去终端回答」用的是同一条路。
|
||||
private let reply = TerminalReply()
|
||||
|
||||
@State private var expanded: Set<String> = []
|
||||
@State private var draft = ""
|
||||
@@ -93,6 +98,7 @@ struct AcceptancePane: View {
|
||||
)
|
||||
|
||||
if !pickerPaths.isEmpty { controls }
|
||||
sessionPromises
|
||||
|
||||
if ledger?.items.isEmpty ?? true {
|
||||
ContentUnavailableView(
|
||||
@@ -214,6 +220,83 @@ struct AcceptancePane: View {
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
|
||||
// MARK: - AI 答应的事做全了没
|
||||
|
||||
/// 这个项目下每个在跑的会话,AI 自己列的 todo 完成情况。
|
||||
///
|
||||
/// 单独一条,不混进下面的清单里:用户问的是「**这一轮**它答应的做全了没」,
|
||||
/// 而清单是按项目跨会话累积的,混在一起答不了这个问题。
|
||||
@ViewBuilder
|
||||
private var sessionPromises: some View {
|
||||
let sessions = store.sessions.filter {
|
||||
currentPath != nil
|
||||
&& AcceptanceStore.projectPath(forCWD: $0.cwd, projects: projects) == currentPath
|
||||
}
|
||||
if !sessions.isEmpty, let path = currentPath {
|
||||
VStack(spacing: 4) {
|
||||
ForEach(sessions) { session in
|
||||
let pending = acceptance.unfinishedAssistantTasks(
|
||||
sessionId: session.sessionId, in: path
|
||||
)
|
||||
let total = acceptance.ledger(for: path).items.filter {
|
||||
$0.sourceSessionId == session.sessionId && $0.origin == .assistantTask
|
||||
}.count
|
||||
if total > 0 {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: pending.isEmpty
|
||||
? "checkmark.circle.fill" : "exclamationmark.circle.fill")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(
|
||||
pending.isEmpty ? IslandTheme.shell : IslandTheme.waiting
|
||||
)
|
||||
Text(session.name ?? session.sessionId.prefix(8).description)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
Text(pending.isEmpty
|
||||
? "AI 答应的 \(total) 项都做完了"
|
||||
: "AI 答应了 \(total) 项,还差 \(pending.count) 项")
|
||||
.font(.system(size: 12)).monospacedDigit()
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
if !pending.isEmpty {
|
||||
Button(notifying == session.sessionId ? "已发" : "告知这个会话") {
|
||||
notify(session: session, pending: pending)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.disabled(notifying != nil)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 10).padding(.vertical, 6)
|
||||
.background(.background.secondary, in: .rect(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
}
|
||||
|
||||
/// 把没做完的推回那个会话的终端。
|
||||
///
|
||||
/// 走 `TerminalReply.send` 注入到对应 pane —— 不是发通知让用户自己切过去,
|
||||
/// 而是直接把话说到那个 Claude 面前。用户的原话:
|
||||
/// 「可以在这边交互的时候映射到对应的 session 里告知」。
|
||||
private func notify(session: AgentSession, pending: [AcceptanceItem]) {
|
||||
notifying = session.sessionId
|
||||
let lines = pending.prefix(8).map { "- \($0.text)" }.joined(separator: "\n")
|
||||
let more = pending.count > 8 ? "\n(另有 \(pending.count - 8) 项)" : ""
|
||||
let text = """
|
||||
【Claude Hub】你自己列的 todo 里这几项还没做完,逐条确认一下现在的状态:
|
||||
\(lines)\(more)
|
||||
"""
|
||||
let reply = self.reply
|
||||
Task {
|
||||
_ = await reply.send(text: text, to: session.sessionId)
|
||||
try? await Task.sleep(for: .seconds(2))
|
||||
notifying = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 项目搜索
|
||||
|
||||
@ViewBuilder
|
||||
|
||||
@@ -217,6 +217,45 @@ public final class AcceptanceStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// 同步 Claude 自己勾掉的那些。
|
||||
///
|
||||
/// 只动 `.assistantTask` 来源、且还没定论的项。上一轮进来时没做完的,
|
||||
/// 这一轮可能已经勾掉了 —— 不同步的话它们会永远挂在「未验收」,
|
||||
/// 用户得手动一条条清,那清单就没人看了。
|
||||
///
|
||||
/// **不碰用户终裁过的项**,理由同 `applyAudit`。
|
||||
public func syncAssistantTasks(done subjects: [String], in projectPath: String) {
|
||||
let keys = Set(subjects.map(Self.dedupKey))
|
||||
guard !keys.isEmpty else { return }
|
||||
mutate(projectPath) { ledger in
|
||||
for index in ledger.items.indices {
|
||||
let item = ledger.items[index]
|
||||
guard item.origin == .assistantTask,
|
||||
!item.isSettledByUser,
|
||||
item.status != .confirmed,
|
||||
keys.contains(Self.dedupKey(item.text))
|
||||
else { continue }
|
||||
ledger.items[index].status = .confirmed
|
||||
ledger.items[index].note = "Claude 自己勾了完成"
|
||||
ledger.items[index].updatedAt = Date()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 某个会话里 AI 答应做但还没做的事。
|
||||
///
|
||||
/// 用于「告知会话」:把这些推回那个终端。按会话而不是按项目 ——
|
||||
/// 用户问的是「这一轮它答应的做全了没」,跨会话混在一起答不了这个问题。
|
||||
public func unfinishedAssistantTasks(sessionId: String, in projectPath: String)
|
||||
-> [AcceptanceItem]
|
||||
{
|
||||
ledger(for: projectPath).items.filter {
|
||||
$0.sourceSessionId == sessionId
|
||||
&& $0.origin == .assistantTask
|
||||
&& $0.needsAttention
|
||||
}
|
||||
}
|
||||
|
||||
/// 去重键:去掉空白和常见标点,忽略大小写。
|
||||
///
|
||||
/// 只做到这个程度就够了 —— 真正的语义去重交给拆解那次模型调用
|
||||
|
||||
@@ -25,6 +25,8 @@ public final class HookCoordinator {
|
||||
private let dedup = HookDedup()
|
||||
private let extractor = AcceptanceExtractor()
|
||||
private let auditor = AcceptanceAuditor()
|
||||
/// 读 Claude 自己的 todo(`~/.claude/tasks/`)。零成本,不调模型。
|
||||
private let tasks = TaskStateReader()
|
||||
/// 正在复核中的项目。同一个项目不并发跑,理由同 `extracting`。
|
||||
private var auditing: Set<String> = []
|
||||
/// 正在拆解中的项目。同一个项目的拆解不并发跑 —— 两次并发会拿到同一份
|
||||
@@ -177,6 +179,9 @@ public final class HookCoordinator {
|
||||
|
||||
let path = AcceptanceStore.projectPath(forCWD: event.cwd, projects: projects)
|
||||
|
||||
// 把 Claude 自己列的 todo 并进清单。零成本,读本地 JSON。
|
||||
collectAssistantTasks(from: event, projectPath: path)
|
||||
|
||||
// 先收自查回答,再决定拦不拦。
|
||||
//
|
||||
// 顺序不能反:上一轮拦下来之后 Claude 的回答就在这条事件的
|
||||
@@ -231,6 +236,44 @@ public final class HookCoordinator {
|
||||
return HookDecision(verdict: .deny, reason: text)
|
||||
}
|
||||
|
||||
/// 把 Claude 自己列的 todo 并进清单。
|
||||
///
|
||||
/// ## 为什么要收它
|
||||
///
|
||||
/// 这份清单**不能当基线** —— 用户需求翻译成它的那一步就已经丢东西了。
|
||||
/// 但先前我把它整个排除了,而用户一眼看出问题:清单里全是他说过的话,
|
||||
/// **AI 自己答应要做的事一条都没有**。
|
||||
///
|
||||
/// 「它自己列了 6 项,做完 3 项就说完事了」是遗漏最直接的证据,
|
||||
/// 而这类承诺不在用户原话里,别处根本抓不到。所以收,但单独标成「AI 计划」。
|
||||
///
|
||||
/// 已完成的直接记成已确认:那是 Claude 自己勾掉的,和它嘴上说做完了不一样 ——
|
||||
/// 勾状态是它边干边写进文件的,事后改动的动机低得多。但仍然只算
|
||||
/// `.confirmed` 而不是 `.accepted`,终裁权还在用户手上。
|
||||
@MainActor
|
||||
private func collectAssistantTasks(from event: HookEvent, projectPath: String) {
|
||||
let tasks = tasks.readTasks(sessionId: event.sessionId)
|
||||
guard !tasks.isEmpty else { return }
|
||||
|
||||
acceptance.merge(
|
||||
tasks.map { task in
|
||||
AcceptanceItem(
|
||||
text: task.subject,
|
||||
origin: .assistantTask,
|
||||
status: task.done ? .confirmed : .open,
|
||||
note: task.done ? "Claude 自己勾了完成" : nil,
|
||||
sourceSessionId: event.sessionId
|
||||
)
|
||||
},
|
||||
into: projectPath
|
||||
)
|
||||
// 已有的那些也要跟着更新状态 —— 上一轮进来时还没做完的,
|
||||
// 这一轮可能已经勾掉了。不同步的话它们会永远挂在「未验收」。
|
||||
acceptance.syncAssistantTasks(
|
||||
done: tasks.filter(\.done).map(\.subject), in: projectPath
|
||||
)
|
||||
}
|
||||
|
||||
/// 收下 Claude 上一轮的逐条自查回答。
|
||||
///
|
||||
/// 解析不出来就什么都不做 —— 它很可能是直接去补做遗漏项了(那其实是好事),
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import HubCore
|
||||
import HubProbe
|
||||
import XCTest
|
||||
@testable import HubUI
|
||||
|
||||
/// 追踪「AI 自己答应要做的事」。
|
||||
///
|
||||
/// 这一档来自用户的一句观察:清单里全是他说过的话,**AI 自己列的 todo
|
||||
/// 一条都没有**。而「它自己列了 6 项,做完 3 项就说完事了」恰恰是遗漏
|
||||
/// 最直接的证据,且这类承诺不在用户原话里,别处根本抓不到。
|
||||
@MainActor
|
||||
final class AssistantTaskTests: XCTestCase {
|
||||
|
||||
private let project = "/tmp/demo-project"
|
||||
private let session = "s1"
|
||||
|
||||
private func task(_ text: String, done: Bool = false) -> AcceptanceItem {
|
||||
AcceptanceItem(
|
||||
text: text, origin: .assistantTask,
|
||||
status: done ? .confirmed : .open, sourceSessionId: session
|
||||
)
|
||||
}
|
||||
|
||||
/// AI 的计划和用户的原话必须**看得出区别**。
|
||||
///
|
||||
/// 混成一档就等于把待核对的自述当成了权威基线 ——
|
||||
/// 而那正是这个功能要防的事。
|
||||
func testAssistantTasksAreDistinguishableFromUserRequirements() {
|
||||
XCTAssertFalse(AcceptanceItem.Origin.assistantTask.isFromUser)
|
||||
XCTAssertFalse(AcceptanceItem.Origin.inferred.isFromUser)
|
||||
XCTAssertTrue(AcceptanceItem.Origin.userPrompt.isFromUser)
|
||||
XCTAssertTrue(AcceptanceItem.Origin.plan.isFromUser)
|
||||
XCTAssertTrue(AcceptanceItem.Origin.manual.isFromUser)
|
||||
XCTAssertEqual(AcceptanceItem.Origin.assistantTask.label, "AI 计划")
|
||||
}
|
||||
|
||||
/// 上一轮没做完、这一轮 Claude 自己勾掉了的,要跟着变成已确认。
|
||||
///
|
||||
/// 不同步的话它们会永远挂在「未验收」,用户得手动一条条清 ——
|
||||
/// 清不动的清单最后就没人看了。
|
||||
func testTasksClaudeFinishedAreSynced() {
|
||||
let store = AcceptanceStore(directory: nil)
|
||||
store.add(task("写数据层"), to: project)
|
||||
store.add(task("接 UI"), to: project)
|
||||
|
||||
store.syncAssistantTasks(done: ["写数据层"], in: project)
|
||||
|
||||
let items = store.ledger(for: project).items
|
||||
XCTAssertEqual(items.first { $0.text == "写数据层" }?.status, .confirmed)
|
||||
XCTAssertEqual(items.first { $0.text == "接 UI" }?.status, .open)
|
||||
}
|
||||
|
||||
/// **同步不能碰用户终裁过的项。** 理由同 applyAudit:
|
||||
/// 用户划掉了「这条不做了」,Claude 那边勾了完成也不该把它翻回来。
|
||||
func testSyncDoesNotTouchUserVerdicts() {
|
||||
let store = AcceptanceStore(directory: nil)
|
||||
let item = task("这条我不要了")
|
||||
store.add(item, to: project)
|
||||
store.setStatus(.dropped, forID: item.id, in: project)
|
||||
|
||||
store.syncAssistantTasks(done: ["这条我不要了"], in: project)
|
||||
|
||||
XCTAssertEqual(store.ledger(for: project).items.first?.status, .dropped)
|
||||
}
|
||||
|
||||
/// 同步只影响 `.assistantTask` 那一档。
|
||||
///
|
||||
/// Claude 勾掉自己的 todo,不代表用户提的需求就做完了 ——
|
||||
/// 两者恰好同名时尤其危险("发布新版本")。
|
||||
func testSyncOnlyAffectsAssistantTasks() {
|
||||
let store = AcceptanceStore(directory: nil)
|
||||
store.add(AcceptanceItem(text: "发布新版本", origin: .userPrompt), to: project)
|
||||
|
||||
store.syncAssistantTasks(done: ["发布新版本"], in: project)
|
||||
|
||||
XCTAssertEqual(
|
||||
store.ledger(for: project).items.first?.status, .open,
|
||||
"Claude 勾掉自己的 todo,不等于用户提的需求做完了"
|
||||
)
|
||||
}
|
||||
|
||||
/// 「还差几项」必须按**会话**算,不能按项目。
|
||||
///
|
||||
/// 用户问的是「这一轮它答应的做全了没」,跨会话混在一起答不了这个问题。
|
||||
func testUnfinishedIsScopedToOneSession() {
|
||||
let store = AcceptanceStore(directory: nil)
|
||||
store.add(task("这一轮的"), to: project)
|
||||
store.add(
|
||||
AcceptanceItem(text: "上一轮的", origin: .assistantTask, sourceSessionId: "other"),
|
||||
to: project
|
||||
)
|
||||
|
||||
let pending = store.unfinishedAssistantTasks(sessionId: session, in: project)
|
||||
|
||||
XCTAssertEqual(pending.map(\.text), ["这一轮的"])
|
||||
}
|
||||
|
||||
func testFinishedTasksAreNotReportedAsPending() {
|
||||
let store = AcceptanceStore(directory: nil)
|
||||
store.add(task("已经做完了", done: true), to: project)
|
||||
|
||||
XCTAssertTrue(store.unfinishedAssistantTasks(sessionId: session, in: project).isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
/// 逐条读 Claude 自己的 todo。
|
||||
final class ClaudeTaskReadingTests: XCTestCase {
|
||||
|
||||
private var root: URL!
|
||||
|
||||
override func setUpWithError() throws {
|
||||
root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("claude-tasks-\(UUID().uuidString)")
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
try? FileManager.default.removeItem(at: root)
|
||||
}
|
||||
|
||||
private func write(session: String, files: [(String, String)]) throws {
|
||||
let dir = root.appendingPathComponent(session)
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
for (name, body) in files {
|
||||
try body.write(
|
||||
to: dir.appendingPathComponent(name), atomically: true, encoding: .utf8
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func testReadsSubjectsAndStatus() throws {
|
||||
try write(session: "session-abcd1234", files: [
|
||||
("1.json", #"{"id":"1","subject":"写数据层","status":"completed"}"#),
|
||||
("2.json", #"{"id":"2","subject":"接 UI","status":"in_progress"}"#),
|
||||
("3.json", #"{"id":"3","subject":"补测试","status":"pending"}"#),
|
||||
])
|
||||
|
||||
let tasks = TaskStateReader(tasksDirectory: root).readTasks(sessionId: "abcd1234efgh")
|
||||
|
||||
XCTAssertEqual(tasks.map(\.subject), ["写数据层", "接 UI", "补测试"])
|
||||
XCTAssertEqual(tasks.map(\.done), [true, false, false])
|
||||
}
|
||||
|
||||
/// **按文件名的数值排序**,不是字符串序 —— 否则 "10" 会排到 "7" 前面,
|
||||
/// 用户看到的任务顺序就和 Claude 界面上的对不上。
|
||||
func testOrdersNumericallyNotLexicographically() throws {
|
||||
try write(session: "session-abcd1234", files: [
|
||||
("7.json", #"{"subject":"第七","status":"pending"}"#),
|
||||
("10.json", #"{"subject":"第十","status":"pending"}"#),
|
||||
])
|
||||
|
||||
let tasks = TaskStateReader(tasksDirectory: root).readTasks(sessionId: "abcd1234")
|
||||
|
||||
XCTAssertEqual(tasks.map(\.subject), ["第七", "第十"])
|
||||
}
|
||||
|
||||
/// 单个文件坏掉不能让整个目录失效 —— 清单是边跑边写的,
|
||||
/// 撞上半截 JSON 完全正常。
|
||||
func testSkipsBrokenFiles() throws {
|
||||
try write(session: "session-abcd1234", files: [
|
||||
("1.json", #"{"subject":"好的","status":"pending"}"#),
|
||||
("2.json", #"{"subject":"半截"#),
|
||||
("3.json", #"{"status":"pending"}"#), // 没有 subject
|
||||
])
|
||||
|
||||
let tasks = TaskStateReader(tasksDirectory: root).readTasks(sessionId: "abcd1234")
|
||||
|
||||
XCTAssertEqual(tasks.map(\.subject), ["好的"])
|
||||
}
|
||||
|
||||
func testMissingSessionYieldsNothing() {
|
||||
XCTAssertTrue(TaskStateReader(tasksDirectory: root).readTasks(sessionId: "nope").isEmpty)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user