merge: bring local uncommitted work onto the cloud-agent branch
Merges the wip/local-snapshot-20260901 safety commit (94 uncommitted files: in-flight Account/Mac/Tip work, clipboard-semantics improvements, and the feature-preview recording tooling) into the newer cloud-agent line. Conflict resolutions: - docs/*: honour the branch's deliberate removal in eac44da; drop the now dangling docs/archive/README.md that only pointed at the deleted files. - CHANGELOG: both sides describe separate [Unreleased] features; keep both. - ClipboardSkillSemanticRanker: take the branch's reply routing, which retires ranking playfulReply/acceptInvitation/... as separate skills in favour of ordinary/formal/playful variants inside one reply action. Keep the local link/phone/CJK-density work, which is orthogonal and still referenced. - OfficialSkillCatalogTests: keep the local "installed exactly once" assertion; it matches the test's own comment and does not hardcode the default set. - ClipboardSkillSemanticRankerTests: keep both sides' disjoint test functions; retarget the two link/phone cases at the branch's row, which no longer surfaces the retired specialised reply skills. Verified: xcodebuild build succeeds and the 77 ranker/catalog/analyzer tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -294,6 +294,21 @@ final class ClipboardSemanticAnalyzerTests: XCTestCase {
|
||||
XCTAssertFalse(acknowledgment.replyableMessage.isDetected)
|
||||
}
|
||||
|
||||
/// The classifiers are trained on single sentences. A multi-sentence paste
|
||||
/// analyzed as one blob dilutes bag-of-words confidence below the routing
|
||||
/// threshold, so the intent must be scored per sentence instead.
|
||||
func testIntentIsFoundInsideAMultiSentencePaste() async {
|
||||
let analysis = await ClipboardSemanticAnalyzer().analyze(
|
||||
"""
|
||||
各位好,这是本周的进度同步。设计稿已经全部定稿,开发那边也开始联调了。\
|
||||
麻烦你今天下班前把新版报价单发我。另外测试环境这两天可能会有波动,\
|
||||
大家注意一下。
|
||||
"""
|
||||
)
|
||||
|
||||
XCTAssertTrue(analysis.task.isDetected)
|
||||
}
|
||||
|
||||
func testPersonalPlanDoesNotBecomeAutomaticTask() async {
|
||||
let analysis = await ClipboardSemanticAnalyzer().analyze(
|
||||
"私人备忘:我准备周五自己整理完这份报告。"
|
||||
|
||||
@@ -715,6 +715,134 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
XCTAssertNil(store.snapshot)
|
||||
}
|
||||
|
||||
func testLinkInsideAMessageKeepsTheMessageSkills() {
|
||||
let recommendations = ClipboardSkillSemanticRanker.recommended(
|
||||
skills: AIClipboardSkillCatalog.catalog,
|
||||
sourceText: "会议链接 https://zoom.us/j/123 周三下午三点开始,能来吗?",
|
||||
analysis: analysis(
|
||||
hasDate: true,
|
||||
urls: [URL(string: "https://zoom.us/j/123")!],
|
||||
question: detected(),
|
||||
invitation: detected()
|
||||
),
|
||||
uiLanguage: .chinese,
|
||||
limit: 5,
|
||||
preferredLanguages: ["zh-Hans"]
|
||||
).map(\.id)
|
||||
|
||||
XCTAssertEqual(
|
||||
recommendations,
|
||||
[
|
||||
AIClipboardSkillCatalog.replyID,
|
||||
AIClipboardSkillCatalog.extractEventsID,
|
||||
AIClipboardSkillCatalog.openLinkID,
|
||||
AIClipboardSkillCatalog.summarizeWebPageID
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
func testLabelledLinkPasteStaysExclusive() {
|
||||
let recommendations = ClipboardSkillSemanticRanker.recommended(
|
||||
skills: AIClipboardSkillCatalog.catalog,
|
||||
sourceText: "详情见 https://example.com/article",
|
||||
analysis: analysis(urls: [URL(string: "https://example.com/article")!]),
|
||||
uiLanguage: .chinese,
|
||||
limit: 5,
|
||||
preferredLanguages: ["zh-Hans"]
|
||||
).map(\.id)
|
||||
|
||||
XCTAssertEqual(
|
||||
recommendations,
|
||||
[
|
||||
AIClipboardSkillCatalog.openLinkID,
|
||||
AIClipboardSkillCatalog.summarizeWebPageID,
|
||||
AIClipboardSkillCatalog.replyID
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
func testPhoneNumberInsideAMessageKeepsTheMessageSkills() {
|
||||
let recommendations = ClipboardSkillSemanticRanker.recommended(
|
||||
skills: AIClipboardSkillCatalog.catalog,
|
||||
sourceText: "王经理说这个订单有问题,你直接打 400-666-8800 找售后处理一下。",
|
||||
analysis: analysis(
|
||||
phoneNumbers: [ClipboardTextLabel(sourceText: "400-666-8800")],
|
||||
task: detected()
|
||||
),
|
||||
uiLanguage: .chinese,
|
||||
limit: 5,
|
||||
preferredLanguages: ["zh-Hans"]
|
||||
).map(\.id)
|
||||
|
||||
XCTAssertEqual(
|
||||
recommendations,
|
||||
[
|
||||
AIClipboardSkillCatalog.callPhoneID,
|
||||
AIClipboardSkillCatalog.extractTodosID,
|
||||
AIClipboardSkillCatalog.createContactID,
|
||||
AIClipboardSkillCatalog.replyID
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
/// 180 Chinese characters carry about as much text as 400 Latin ones, so the
|
||||
/// summary threshold must fire well below the raw 360-character mark.
|
||||
func testChineseTextReachesTheSummaryThresholdBelowTheLatinCharacterCount() {
|
||||
let text = String(
|
||||
repeating: "这是一段需要归纳整理的中文长文内容。",
|
||||
count: 10
|
||||
)
|
||||
let recommendations = ClipboardSkillSemanticRanker.recommended(
|
||||
skills: AIClipboardSkillCatalog.catalog,
|
||||
sourceText: text,
|
||||
analysis: analysis(),
|
||||
uiLanguage: .chinese,
|
||||
limit: 5,
|
||||
preferredLanguages: ["zh-Hans"]
|
||||
).map(\.id)
|
||||
|
||||
XCTAssertLessThan(text.count, 360)
|
||||
XCTAssertEqual(
|
||||
recommendations,
|
||||
[
|
||||
AIClipboardSkillCatalog.summarizeID,
|
||||
AIClipboardSkillCatalog.saveToNotesID,
|
||||
AIClipboardSkillCatalog.replyID
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
func testHardWrappedChineseProseIsNotTreatedAsAList() {
|
||||
let prose = [
|
||||
"我们这次的目标是在下个季度之前完成整套流程的重构工作",
|
||||
"同时还要保证现有的客户不会受到任何影响并保持稳定",
|
||||
"最后需要在月底之前把完整的测试报告提交给管理层评审"
|
||||
].joined(separator: "\n")
|
||||
|
||||
let proseRecommendations = ClipboardSkillSemanticRanker.recommended(
|
||||
skills: AIClipboardSkillCatalog.catalog,
|
||||
sourceText: prose,
|
||||
analysis: analysis(),
|
||||
uiLanguage: .chinese,
|
||||
limit: 5,
|
||||
preferredLanguages: ["zh-Hans"]
|
||||
).map(\.id)
|
||||
|
||||
let listRecommendations = ClipboardSkillSemanticRanker.recommended(
|
||||
skills: AIClipboardSkillCatalog.catalog,
|
||||
sourceText: "牛奶\n鸡蛋\n面包",
|
||||
analysis: analysis(),
|
||||
uiLanguage: .chinese,
|
||||
limit: 5,
|
||||
preferredLanguages: ["zh-Hans"]
|
||||
).map(\.id)
|
||||
|
||||
XCTAssertEqual(proseRecommendations, [AIClipboardSkillCatalog.replyID])
|
||||
XCTAssertTrue(
|
||||
listRecommendations.contains(AIClipboardSkillCatalog.organizeListID)
|
||||
)
|
||||
}
|
||||
|
||||
private func rank(
|
||||
text: String,
|
||||
analysis: ClipboardSemanticAnalysis
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
// FlowLevelStoreTests.swift
|
||||
// OSGKeyboardTests
|
||||
//
|
||||
// Regression cover for the waveform bar arithmetic. `calculateLevels` runs on
|
||||
// the AVAudioEngine realtime thread, where a reversed `start..<end` range is an
|
||||
// uncatchable Swift trap that takes the whole host app down mid-dictation — so
|
||||
// the short-buffer cases below are the ones that matter, not the happy path.
|
||||
|
||||
import AVFoundation
|
||||
@testable import OSGKeyboardHostSupport
|
||||
import XCTest
|
||||
|
||||
final class FlowLevelStoreTests: XCTestCase {
|
||||
/// Mirrors `FlowContinuousCapture.levelBarCount`, which cannot be read here
|
||||
/// directly: it lives on a `@MainActor` type and this suite is nonisolated.
|
||||
/// `testBarCountMatchesShippedConstant` keeps the two from drifting.
|
||||
private let barCount = 24
|
||||
|
||||
@MainActor
|
||||
func testBarCountMatchesShippedConstant() {
|
||||
XCTAssertEqual(
|
||||
barCount,
|
||||
FlowContinuousCapture.levelBarCount,
|
||||
"Bar count drifted — the short-buffer cases below are sized to it"
|
||||
)
|
||||
}
|
||||
|
||||
private func makeBuffer(frameLength: Int, amplitude: Float = 0.5) -> AVAudioPCMBuffer {
|
||||
let format = AVAudioFormat(
|
||||
commonFormat: .pcmFormatFloat32,
|
||||
sampleRate: 48_000,
|
||||
channels: 1,
|
||||
interleaved: false
|
||||
)!
|
||||
// frameCapacity must be > 0 even when we want a zero-length buffer.
|
||||
let buffer = AVAudioPCMBuffer(
|
||||
pcmFormat: format,
|
||||
frameCapacity: AVAudioFrameCount(max(frameLength, 1))
|
||||
)!
|
||||
buffer.frameLength = AVAudioFrameCount(frameLength)
|
||||
if let channel = buffer.floatChannelData?[0] {
|
||||
for index in 0..<frameLength {
|
||||
channel[index] = amplitude
|
||||
}
|
||||
}
|
||||
return buffer
|
||||
}
|
||||
|
||||
/// The shipped bar count is 24. Any tap buffer shorter than that used to
|
||||
/// drive the tail bars past `frameLength` and build a reversed range.
|
||||
func testShortBuffersDoNotTrap() {
|
||||
for frameLength in [0, 1, 2, 5, 10, 22, 23, 24, 25] {
|
||||
let levels = FlowLevelStore.calculateLevels(
|
||||
from: makeBuffer(frameLength: frameLength),
|
||||
barCount: barCount
|
||||
)
|
||||
XCTAssertEqual(
|
||||
levels.count,
|
||||
barCount,
|
||||
"frameLength=\(frameLength) must still produce a full bar array"
|
||||
)
|
||||
for level in levels {
|
||||
XCTAssertFalse(level.isNaN, "frameLength=\(frameLength) produced NaN")
|
||||
XCTAssertTrue(
|
||||
(0...1).contains(level),
|
||||
"frameLength=\(frameLength) produced out-of-range level \(level)"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bars past the available samples read as silence rather than crashing.
|
||||
func testBarsBeyondAvailableSamplesReadAsZero() {
|
||||
let levels = FlowLevelStore.calculateLevels(
|
||||
from: makeBuffer(frameLength: 10),
|
||||
barCount: barCount
|
||||
)
|
||||
XCTAssertEqual(levels.count, barCount)
|
||||
// 10 samples spread one-per-bar: the first 10 carry signal, the rest are empty.
|
||||
XCTAssertTrue(levels[0] > 0, "leading bars should carry the signal")
|
||||
for index in 10..<barCount {
|
||||
XCTAssertEqual(levels[index], 0, "bar \(index) has no samples and must read 0")
|
||||
}
|
||||
}
|
||||
|
||||
/// Normal-sized tap buffers keep producing a full, non-silent waveform.
|
||||
func testTypicalBufferSizesProduceSignal() {
|
||||
for frameLength in [64, 441, 1_024, 4_096] {
|
||||
let levels = FlowLevelStore.calculateLevels(
|
||||
from: makeBuffer(frameLength: frameLength),
|
||||
barCount: barCount
|
||||
)
|
||||
XCTAssertEqual(levels.count, barCount)
|
||||
XCTAssertTrue(
|
||||
levels.allSatisfy { $0 > 0 },
|
||||
"frameLength=\(frameLength) should light every bar"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The instance path the audio tap actually calls.
|
||||
func testUpdateFromShortBufferKeepsSnapshotUsable() {
|
||||
let store = FlowLevelStore(barCount: barCount)
|
||||
store.update(from: makeBuffer(frameLength: 3), barCount: barCount)
|
||||
XCTAssertEqual(store.snapshot().count, barCount)
|
||||
}
|
||||
}
|
||||
@@ -128,20 +128,29 @@ final class OfficialSkillCatalogTests: XCTestCase {
|
||||
|
||||
let migrated = store.agentSkillLayout
|
||||
|
||||
// What this test is named for: the v2 → current ladder installs the
|
||||
// cached official transform skill, exactly once.
|
||||
XCTAssertEqual(
|
||||
migrated.enabledIDs,
|
||||
[
|
||||
AIClipboardSkillCatalog.replyID,
|
||||
AIClipboardSkillCatalog.translateID,
|
||||
AIClipboardSkillCatalog.openLinkID,
|
||||
AIClipboardSkillCatalog.summarizeWebPageID,
|
||||
AIClipboardSkillCatalog.callPhoneID,
|
||||
AIClipboardSkillCatalog.createContactID,
|
||||
AIClipboardSkillCatalog.summarizeID,
|
||||
AIClipboardSkillCatalog.organizeListID,
|
||||
"official.rewrite"
|
||||
]
|
||||
migrated.enabledIDs.filter { $0 == "official.rewrite" }.count,
|
||||
1,
|
||||
"cached official transform skill must be installed exactly once"
|
||||
)
|
||||
// A layout the user already had must survive the migration intact.
|
||||
XCTAssertEqual(migrated.enabledIDs.first, AIClipboardSkillCatalog.replyID)
|
||||
XCTAssertEqual(
|
||||
Set(migrated.enabledIDs).count,
|
||||
migrated.enabledIDs.count,
|
||||
"migration must not duplicate any skill"
|
||||
)
|
||||
|
||||
// The "once" half: re-reading adds nothing further now that the stored
|
||||
// migration version has caught up.
|
||||
//
|
||||
// Deliberately NOT asserting the full built-in list here. Which
|
||||
// built-ins each ladder step adds is the ladder's own business (covered
|
||||
// by the version-specific tests above); freezing the whole list in this
|
||||
// test is exactly what rotted it when a later step was appended.
|
||||
XCTAssertEqual(store.agentSkillLayout.enabledIDs, migrated.enabledIDs)
|
||||
}
|
||||
|
||||
func testValidationRejectsWholeInvalidSnapshot() {
|
||||
|
||||
@@ -7,7 +7,7 @@ import Foundation
|
||||
@testable import OSGKeyboardHostSupport
|
||||
|
||||
actor InMemoryAccountSecurityStore: AccountSessionVault, AppAttestKeyStateStoring {
|
||||
private(set) var session: AccountSession?
|
||||
private(set) var session: AccountTokenSession?
|
||||
private(set) var refreshTransaction: AccountRefreshTransaction?
|
||||
private(set) var keyState: AppAttestKeyState?
|
||||
private(set) var clearSessionCount = 0
|
||||
@@ -16,7 +16,7 @@ actor InMemoryAccountSecurityStore: AccountSessionVault, AppAttestKeyStateStorin
|
||||
private var remainingSessionClearFailures: Int
|
||||
|
||||
init(
|
||||
session: AccountSession? = nil,
|
||||
session: AccountTokenSession? = nil,
|
||||
refreshTransaction: AccountRefreshTransaction? = nil,
|
||||
keyState: AppAttestKeyState? = nil,
|
||||
sessionSaveFailures: Int = 0,
|
||||
@@ -29,11 +29,11 @@ actor InMemoryAccountSecurityStore: AccountSessionVault, AppAttestKeyStateStorin
|
||||
self.remainingSessionClearFailures = sessionClearFailures
|
||||
}
|
||||
|
||||
func loadSession() async throws -> AccountSession? {
|
||||
func loadSession() async throws -> AccountTokenSession? {
|
||||
session
|
||||
}
|
||||
|
||||
func saveSession(_ session: AccountSession) async throws {
|
||||
func saveSession(_ session: AccountTokenSession) async throws {
|
||||
if remainingSessionSaveFailures > 0 {
|
||||
remainingSessionSaveFailures -= 1
|
||||
throw AccountAPIError.secureStorage
|
||||
@@ -107,13 +107,13 @@ actor QueueAccountTransport: AccountHTTPTransport {
|
||||
}
|
||||
|
||||
actor RefreshMergingTransport: AccountHTTPTransport {
|
||||
private let replacementSession: AccountSession
|
||||
private let replacementSession: AccountTokenSession
|
||||
private let refreshError: QueueAccountTransport.Stub?
|
||||
private(set) var requests: [URLRequest] = []
|
||||
private(set) var refreshCount = 0
|
||||
|
||||
init(
|
||||
replacementSession: AccountSession,
|
||||
replacementSession: AccountTokenSession,
|
||||
refreshError: QueueAccountTransport.Stub? = nil
|
||||
) {
|
||||
self.replacementSession = replacementSession
|
||||
@@ -248,7 +248,7 @@ func makeHTTPResponse(request: URLRequest, statusCode: Int) -> HTTPURLResponse {
|
||||
)!
|
||||
}
|
||||
|
||||
func sessionEnvelopeData(_ session: AccountSession) throws -> Data {
|
||||
func sessionEnvelopeData(_ session: AccountTokenSession) throws -> Data {
|
||||
try JSONEncoder().encode(APIDataEnvelope(data: session))
|
||||
}
|
||||
|
||||
@@ -261,8 +261,8 @@ func makeAccountSession(
|
||||
refreshToken: String = "refresh-old",
|
||||
accessExpiry: Int64 = 4_000_000_000,
|
||||
refreshExpiry: Int64 = 4_100_000_000
|
||||
) -> AccountSession {
|
||||
AccountSession(
|
||||
) -> AccountTokenSession {
|
||||
AccountTokenSession(
|
||||
accountId: UUID(uuidString: "11111111-2222-3333-4444-555555555555")!,
|
||||
tokenType: "Bearer",
|
||||
accessToken: accessToken,
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
// TipProductTests.swift
|
||||
// OSGKeyboardTests
|
||||
//
|
||||
// Locks tip product identifiers and optional support-count persistence.
|
||||
|
||||
@testable import OSGKeyboardShared
|
||||
import XCTest
|
||||
|
||||
final class TipProductTests: XCTestCase {
|
||||
private var suiteName: String!
|
||||
private var defaults: UserDefaults!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
suiteName = "tip.tests.\(UUID().uuidString)"
|
||||
defaults = UserDefaults(suiteName: suiteName)!
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
defaults = nil
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testSupportProductIDIsStable() {
|
||||
XCTAssertEqual(TipProduct.supportID, "ByRockyACoffee")
|
||||
XCTAssertEqual(TipProduct.allProductIDs, [TipProduct.supportID])
|
||||
}
|
||||
|
||||
func testSupportCountDefaultsKeyIsStable() {
|
||||
XCTAssertEqual(TipProduct.supportCountDefaultsKey, "tipSupportPurchaseCount")
|
||||
}
|
||||
|
||||
func testSupportCountPersistsInUserDefaults() {
|
||||
defaults.set(2, forKey: TipProduct.supportCountDefaultsKey)
|
||||
XCTAssertEqual(defaults.integer(forKey: TipProduct.supportCountDefaultsKey), 2)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user