feat(keyboard): add clipboard AI skills, hint keywords, and voice session fixes

Idle chips show entities with category icons; a fresh copy surfaces Reply/Summarize/Translate; abort/cancel/empty-tap no longer leave the mic stuck.
This commit is contained in:
Rocky
2026-08-13 15:47:32 +08:00
parent 9f308fadd2
commit 631617ee53
48 changed files with 1627 additions and 345 deletions
@@ -188,6 +188,28 @@ final class FlowKeyboardPoliciesTests: XCTestCase {
),
.none
)
let processing = FlowReadySnapshot(
sessionId: sessionId,
ready: false,
reason: .processing,
engineMode: "cloud",
localeId: "zh-Hans",
busyUtteranceId: utteranceId,
hostGeneration: "gen-1"
)
XCTAssertEqual(
FlowKeyboardAdoptBusyPolicy.decide(
snapshot: processing,
currentHostGeneration: "gen-1",
isFlowRecording: false,
isAwaitingFlowResult: false,
lastConsumedUtteranceId: nil,
lastStoppedUtteranceId: utteranceId
),
.none,
"an aborted utterance must not come back as 识别中 on the next open"
)
}
func testIgnoresDeadHostGenerationSnapshot() {
@@ -234,6 +256,138 @@ final class FlowKeyboardPoliciesTests: XCTestCase {
)
}
func testStaleDeliveredProcessingRequiresMatchingAckWithoutResult() {
let busyId = UUID()
let sessionId = UUID()
XCTAssertFalse(
FlowKeyboardAdoptBusyPolicy.isStaleDeliveredProcessing(
busyUtteranceId: busyId,
latestResult: nil,
latestAck: nil
),
"live ASR/LLM has no result and no ack yet — must wait, not abort"
)
let otherAck = FlowAck(
sessionId: sessionId,
utteranceId: UUID(),
commandSeq: 1
)
XCTAssertFalse(
FlowKeyboardAdoptBusyPolicy.isStaleDeliveredProcessing(
busyUtteranceId: busyId,
latestResult: nil,
latestAck: otherAck
),
"ack for a previous utterance must not abort the live one"
)
let matchingAck = FlowAck(
sessionId: sessionId,
utteranceId: busyId,
commandSeq: 1
)
XCTAssertTrue(
FlowKeyboardAdoptBusyPolicy.isStaleDeliveredProcessing(
busyUtteranceId: busyId,
latestResult: nil,
latestAck: matchingAck
),
"acked + empty mailbox + still processing is a leaked gate"
)
let liveResult = FlowResult(
sessionId: sessionId,
utteranceId: busyId,
commandSeq: 1,
status: .streaming,
text: "draft"
)
XCTAssertFalse(
FlowKeyboardAdoptBusyPolicy.isStaleDeliveredProcessing(
busyUtteranceId: busyId,
latestResult: liveResult,
latestAck: matchingAck
),
"result still sitting for this utterance is waitable, not stale"
)
XCTAssertFalse(
FlowKeyboardAdoptBusyPolicy.isStaleDeliveredProcessing(
busyUtteranceId: UUID(),
latestResult: liveResult,
latestAck: matchingAck
)
)
}
func testTerminalStorePolicyRejectsAbortAndReplacement() {
let live = UUID()
XCTAssertTrue(
FlowTerminalStorePolicy.canStore(
currentUtteranceId: live,
finishedUtteranceId: live,
alreadyTerminal: false
)
)
XCTAssertFalse(
FlowTerminalStorePolicy.canStore(
currentUtteranceId: nil,
finishedUtteranceId: live,
alreadyTerminal: false
),
"abort cleared currentUtteranceId — do not deliver"
)
XCTAssertFalse(
FlowTerminalStorePolicy.canStore(
currentUtteranceId: UUID(),
finishedUtteranceId: live,
alreadyTerminal: false
),
"a newer utterance owns the gate"
)
XCTAssertFalse(
FlowTerminalStorePolicy.canStore(
currentUtteranceId: live,
finishedUtteranceId: live,
alreadyTerminal: true
),
"already terminal — abort already claimed"
)
}
func testAckGateDropsProcessingOnlyForTheLiveUtterance() {
let live = UUID()
XCTAssertTrue(
FlowHostAckGatePolicy.shouldDropProcessingGate(
ackUtteranceId: live,
currentUtteranceId: live,
isUtteranceProcessing: true
)
)
XCTAssertFalse(
FlowHostAckGatePolicy.shouldDropProcessingGate(
ackUtteranceId: live,
currentUtteranceId: live,
isUtteranceProcessing: false
)
)
XCTAssertFalse(
FlowHostAckGatePolicy.shouldDropProcessingGate(
ackUtteranceId: live,
currentUtteranceId: UUID(),
isUtteranceProcessing: true
),
"must not clobber a newer utterance"
)
XCTAssertFalse(
FlowHostAckGatePolicy.shouldDropProcessingGate(
ackUtteranceId: live,
currentUtteranceId: nil,
isUtteranceProcessing: true
)
)
}
// MARK: - Result matcher
func testMatchingResultRequiresAlignedSessionAndUtterance() {
@@ -371,3 +525,58 @@ final class FlowKeyboardPoliciesTests: XCTestCase {
)
}
}
// MARK: - Empty tap skip
extension FlowKeyboardPoliciesTests {
func testEmptyTapSkipRequiresShortDuration() {
XCTAssertTrue(
FlowEmptyTapSkipPolicy.shouldSkip(
durationSeconds: 0.2,
sampleCount: 0,
peakAmplitude: nil
)
)
XCTAssertFalse(
FlowEmptyTapSkipPolicy.shouldSkip(
durationSeconds: 0.35,
sampleCount: 0,
peakAmplitude: nil
)
)
}
func testEmptyTapSkipTreatsMissingSamplesAsSilence() {
XCTAssertTrue(
FlowEmptyTapSkipPolicy.shouldSkip(
durationSeconds: 0.1,
sampleCount: 16,
peakAmplitude: nil
)
)
}
func testEmptyTapSkipKeepsShortSpeech() {
XCTAssertFalse(
FlowEmptyTapSkipPolicy.shouldSkip(
durationSeconds: 0.2,
sampleCount: 3_200,
peakAmplitude: 0.2
)
)
}
func testEmptyTapSkipDropsShortSilence() {
XCTAssertTrue(
FlowEmptyTapSkipPolicy.shouldSkip(
durationSeconds: 0.2,
sampleCount: 3_200,
peakAmplitude: 0.001
)
)
XCTAssertEqual(
FlowEmptyTapSkipPolicy.peakAbs([0.001, -0.004, 0.002]),
0.004
)
}
}