删掉 Tauri 版(app/),全部重写为 SwiftUI + SwiftPM(HubKit/,8.2k 行源码 + 2k 行测试,131 个测试)。核心动机是旧版"点通知跳不到对应终端"—— 根因是身份链路全程用会漂移的字符串:项目名 → tmux 窗口名 → pane_title → AppleScript 子串匹配。实测 9 个 tmux 窗口只有 2 个能和 projects.yaml 对上, 其中一个窗口名是 `2.1.173`(tmux automatic-rename 把它改成了 claude 的版本号)。 主要变化 - 会话状态直接读 ~/.claude/sessions/<PID>.json(Claude Code 自己维护的权威源), 不再用 spinner 字符猜 busy;死进程的残留 json 用 kill(pid,0) 过滤 - 跳转改用 iTerm jobPid + 进程祖先链求交,全程只有整数 PID 和 UUID; bg 会话不在任何终端里,单独用稳定 job 名兜底。实测 9/9 绑定成功 - hook 从 bash 换成 Swift 的 hubctl,走 Unix socket;Hook 没运行时立即放行 - 高风险操作在岛上审批,六重防误点;拒绝是超时和异常的默认值 - 主窗口:会话 / 项目 / 用量 / 审批日志 / 设置 修掉的真实缺陷(详见 HubKit/NOTES.md) - 超时阶梯是反的:ApprovalCoordinator 和 hubctl 读超时都是 60s,同时到期时 hubctl 按 fail-open 放行了一条已被服务端判定拒绝的 git push --force origin main。 改成 60/65/75/90 严格递增,并区分「连不上」(放行)与「连上但没应答」(拒绝) - 点击全部被吞:app 是 .accessory + panel 是 .nonactivatingPanel, NSHostingView.acceptsFirstMouse 默认 false,每次点击都算 first mouse 被丢弃 - hitTest 假设 NSHostingView 恒为 flipped,而 isFlipped 是 final 的可读写属性 - 形态切换靠替换 rootView,导致动画不可能发生且 @State 每次归零 - 全局 leftMouseDown 监听会收到点在自己身上的点击,展开态一点就收起 - hook 进程死后审批卡不消失,且会挡住后面的请求(按 clientPid 扫描清理) - app 读的 projects.yaml 不是脚本维护的那份,且那份被写坏成 `projects: []` + 117 条内容,解析器静默返回 0 —— 三个页面因此全空 - 用量对 945MB/341 个 jsonl 整份读进内存,跑不完;改流式 + 增量缓存后 7 天首扫 3.6s、命中缓存 0.22s、全量 4s - 辅助二进制放 Contents/MacOS/ 且共用 bundle id 会被 exec 瞬间 SIGKILL - 同一 bundle id 对应多个 .app 让存活判断和 TCC 授权互相打架 刻意的取舍 - 放弃 Liquid Glass 做岛的基底:它会跟着背景亮度双向补偿,而刘海恒为纯黑, 两者物理上互斥。改用固定 86% 暗色基底 + 背景模糊 + 边缘高光 - 用 SwiftPM + 打包脚本而不是 .xcodeproj:手写 xcodeproj 的 XML 无法 review, 且 SwiftPM 保住了 swift test Co-authored-by: wuguangan <anjaihd0206@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
98 lines
4.6 KiB
Swift
98 lines
4.6 KiB
Swift
import XCTest
|
|
@testable import HubCore
|
|
|
|
final class AgentSessionTests: XCTestCase {
|
|
|
|
/// 真实样本,逐字段抄自本机 ~/.claude/sessions/34624.json。
|
|
private let realBusySample = """
|
|
{"pid":34624,"sessionId":"8ba8f1c1-0039-4295-8193-78fa6056578f",\
|
|
"cwd":"/Users/dev/Documents/code/shopify/storefront-a","startedAt":1785140285143,\
|
|
"procStart":"Mon Jul 27 08:18:03 2026","version":"2.1.220","peerProtocol":1,\
|
|
"kind":"interactive","entrypoint":"cli","name":"storefront-a-c3","nameSource":"derived",\
|
|
"status":"busy","updatedAt":1785225171453,"statusUpdatedAt":1785225171453}
|
|
"""
|
|
|
|
func testDecodesRealBusySample() throws {
|
|
let session = try XCTUnwrap(AgentSession.decode(from: Data(realBusySample.utf8)))
|
|
|
|
XCTAssertEqual(session.sessionId, "8ba8f1c1-0039-4295-8193-78fa6056578f")
|
|
XCTAssertEqual(session.pid, 34624)
|
|
XCTAssertEqual(session.cwd, "/Users/dev/Documents/code/shopify/storefront-a")
|
|
XCTAssertEqual(session.name, "storefront-a-c3")
|
|
XCTAssertEqual(session.status, .busy)
|
|
XCTAssertEqual(session.kind, .interactive)
|
|
XCTAssertEqual(session.version, "2.1.220")
|
|
XCTAssertNil(session.waitingFor)
|
|
XCTAssertEqual(session.fallbackProjectName, "storefront-a")
|
|
}
|
|
|
|
func testDecodesWaitingWithReason() throws {
|
|
let json = """
|
|
{"pid":34624,"sessionId":"abc","cwd":"/tmp/x","status":"waiting",\
|
|
"kind":"interactive","waitingFor":"input needed"}
|
|
"""
|
|
let session = try XCTUnwrap(AgentSession.decode(from: Data(json.utf8)))
|
|
XCTAssertEqual(session.status, .waiting)
|
|
XCTAssertEqual(session.waitingFor, "input needed")
|
|
}
|
|
|
|
func testDecodesBackgroundSessionWithJobId() throws {
|
|
let json = """
|
|
{"pid":23402,"sessionId":"171d69d9","cwd":"/tmp/x","kind":"bg",\
|
|
"status":"shell","jobId":"171d69d9"}
|
|
"""
|
|
let session = try XCTUnwrap(AgentSession.decode(from: Data(json.utf8)))
|
|
XCTAssertEqual(session.kind, .bg)
|
|
XCTAssertEqual(session.jobId, "171d69d9")
|
|
XCTAssertEqual(session.status, .shell)
|
|
}
|
|
|
|
/// Claude Code 将来可能加新的 status 取值。不能因此丢掉整个会话,
|
|
/// 也不能把未知状态误判成 idle(那会让它在 UI 上装作没事)。
|
|
func testUnknownStatusIsPreservedNotDropped() throws {
|
|
let json = #"{"pid":1,"sessionId":"x","cwd":"/tmp","status":"compacting"}"#
|
|
let session = try XCTUnwrap(AgentSession.decode(from: Data(json.utf8)))
|
|
XCTAssertEqual(session.status, .unknown)
|
|
XCTAssertEqual(session.rawStatus, "compacting")
|
|
}
|
|
|
|
/// schema 变动导致缺字段时,只有三个硬要求缺失才该整条丢弃。
|
|
func testRejectsRecordsMissingIdentityFields() {
|
|
XCTAssertNil(AgentSession.decode(from: Data(#"{"sessionId":"x","cwd":"/tmp"}"#.utf8)))
|
|
XCTAssertNil(AgentSession.decode(from: Data(#"{"pid":1,"cwd":"/tmp"}"#.utf8)))
|
|
XCTAssertNil(AgentSession.decode(from: Data(#"{"pid":1,"sessionId":"x"}"#.utf8)))
|
|
XCTAssertNil(AgentSession.decode(from: Data(#"{"pid":1,"sessionId":"","cwd":"/tmp"}"#.utf8)))
|
|
XCTAssertNil(AgentSession.decode(from: Data("not json".utf8)))
|
|
}
|
|
|
|
func testMissingOptionalFieldsStillDecodes() throws {
|
|
let json = #"{"pid":1,"sessionId":"x","cwd":"/tmp/proj"}"#
|
|
let session = try XCTUnwrap(AgentSession.decode(from: Data(json.utf8)))
|
|
XCTAssertEqual(session.status, .unknown)
|
|
XCTAssertEqual(session.kind, .unknown)
|
|
XCTAssertNil(session.name)
|
|
XCTAssertNil(session.startedAt)
|
|
}
|
|
|
|
/// 排序即 UI 优先级。waiting 必须永远第一,unknown 必须永远最后 ——
|
|
/// 后者保证 Claude 新增状态取值时不会意外抢占最显眼的位置。
|
|
func testStatusOrderingPutsWaitingFirstAndUnknownLast() {
|
|
let shuffled: [SessionStatus] = [.idle, .unknown, .busy, .waiting, .shell]
|
|
XCTAssertEqual(shuffled.sorted(), [.waiting, .busy, .shell, .idle, .unknown])
|
|
}
|
|
|
|
func testIsActiveExcludesIdleAndUnknown() {
|
|
XCTAssertTrue(SessionStatus.busy.isActive)
|
|
XCTAssertTrue(SessionStatus.waiting.isActive)
|
|
XCTAssertTrue(SessionStatus.shell.isActive)
|
|
XCTAssertFalse(SessionStatus.idle.isActive)
|
|
XCTAssertFalse(SessionStatus.unknown.isActive)
|
|
}
|
|
|
|
func testMillisecondTimestampsConvertToDates() throws {
|
|
let session = try XCTUnwrap(AgentSession.decode(from: Data(realBusySample.utf8)))
|
|
let started = try XCTUnwrap(session.startedAt)
|
|
XCTAssertEqual(started.timeIntervalSince1970, 1_785_140_285.143, accuracy: 0.001)
|
|
}
|
|
}
|