mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 03:21:36 +00:00
* feat(ask-user): follow-up harness slice * feat(ask-user): follow-up channels slice * feat(ask-user): follow-up native slice * feat(ui): dock question panel above composer with stepper and compact stream summaries * docs: refresh follow-up integration maps * test(ui): align terminal summary proof * fix(infra): echo declared option answers in terminal status when free-text is allowed * fix(infra): keep reaction answering when display labels are formatter-adjusted * fix(agents): settle plain-text claims only after question registration commits * fix(agents,apps): commit-ordered claim persistence, claim-aware prompt delivery, non-blocking question refresh * fix(harness,infra): reaction-appropriate question copy, caller presentations honored * fix(native,infra,agents): local-expiry eviction, value-addressed reactions, reserve-before-request * fix(infra,android): dual-mode question resolver for compact callbacks; reset terminal retention on replayed pending * fix(harness,discord): claim-aware prompt delivery in run helper; escape finalization labels * fix(macos): merge transient-content visibility with question cards after main sync * fix: repair ask user follow-up CI * test: update limited bootstrap scope expectation * fix: retain shared question card API * chore: refresh native i18n inventory
115 lines
4.5 KiB
Swift
115 lines
4.5 KiB
Swift
import Foundation
|
|
import OpenClawProtocol
|
|
import Testing
|
|
@testable import OpenClawChatUI
|
|
|
|
@MainActor
|
|
private func questionRecord(
|
|
multiSelect: Bool = false,
|
|
isOther: Bool = true,
|
|
createdAtMs: Int = 1_000_000,
|
|
expiresAtMs: Int = 4_000_000_000_000,
|
|
status: QuestionStatus = .pending) -> QuestionRecord
|
|
{
|
|
QuestionRecord(
|
|
id: "ask_123",
|
|
questions: [
|
|
Question(
|
|
id: "meal",
|
|
header: "Meal",
|
|
question: "Choose dinner",
|
|
options: [
|
|
QuestionOption(label: "Pizza", description: "Fast"),
|
|
QuestionOption(label: "Tacos"),
|
|
],
|
|
multiselect: multiSelect,
|
|
isother: isOther),
|
|
],
|
|
agentid: "main",
|
|
sessionkey: "agent:main:main",
|
|
createdatms: createdAtMs,
|
|
expiresatms: expiresAtMs,
|
|
status: status)
|
|
}
|
|
|
|
@MainActor
|
|
@Test func `question card single select and other are exclusive`() {
|
|
let model = OpenClawQuestionCardModel(record: questionRecord())
|
|
model.toggleOption(questionID: "meal", label: "Pizza")
|
|
#expect(model.beginSubmission() == ["meal": ["Pizza"]])
|
|
model.failSubmission("retry")
|
|
|
|
model.setOtherText(questionID: "meal", value: " Salad ")
|
|
#expect(model.selectedOptions["meal"]?.isEmpty == true)
|
|
#expect(model.beginSubmission() == ["meal": ["Salad"]])
|
|
}
|
|
|
|
@MainActor
|
|
@Test func `question card multi select uses declared option order`() {
|
|
let model = OpenClawQuestionCardModel(record: questionRecord(multiSelect: true))
|
|
model.toggleOption(questionID: "meal", label: "Tacos")
|
|
model.toggleOption(questionID: "meal", label: "Pizza")
|
|
#expect(model.beginSubmission() == ["meal": ["Pizza", "Tacos"]])
|
|
}
|
|
|
|
@MainActor
|
|
@Test func `question card maps expiry and answer origin`() {
|
|
let now = Date(timeIntervalSince1970: 1500)
|
|
let expired = OpenClawQuestionCardModel(record: questionRecord(expiresAtMs: 1_499_000))
|
|
#expect(expired.status(at: now) == .expired)
|
|
#expect(expired.remainingSeconds(at: now) == 0)
|
|
|
|
let remote = OpenClawQuestionCardModel(record: questionRecord())
|
|
remote.apply(resolved: OpenClawQuestionResolvedEvent(id: remote.id, status: .answered))
|
|
#expect(remote.status(at: Date(timeIntervalSince1970: 1500)) == .answeredElsewhere)
|
|
|
|
let local = OpenClawQuestionCardModel(record: questionRecord())
|
|
local.markAnsweredLocally()
|
|
local.apply(resolved: OpenClawQuestionResolvedEvent(id: local.id, status: .answered))
|
|
#expect(local.status(at: Date(timeIntervalSince1970: 1500)) == .answered)
|
|
}
|
|
|
|
@MainActor
|
|
@Test func `question card pending refresh preserves submission`() {
|
|
let model = OpenClawQuestionCardModel(record: questionRecord(expiresAtMs: Int.max))
|
|
model.toggleOption(questionID: "meal", label: "Pizza")
|
|
#expect(model.beginSubmission() != nil)
|
|
|
|
#expect(model.apply(record: questionRecord(createdAtMs: 2_000_000, expiresAtMs: Int.max)))
|
|
#expect(model.status(at: Date(timeIntervalSince1970: 1500)) == .submitting)
|
|
|
|
#expect(model.apply(record: questionRecord(createdAtMs: 2_000_000, expiresAtMs: Int.max, status: .answered)))
|
|
#expect(model.status(at: Date(timeIntervalSince1970: 1500)) == .answeredElsewhere)
|
|
}
|
|
|
|
@MainActor
|
|
@Test func `question card retains terminal feedback for gateway grace`() {
|
|
let observedAt = Date(timeIntervalSince1970: 1500)
|
|
let model = OpenClawQuestionCardModel(record: questionRecord())
|
|
model.apply(resolved: .init(id: model.id, status: .answered), at: observedAt)
|
|
|
|
#expect(model.shouldRetainAfterList(at: observedAt.addingTimeInterval(14)))
|
|
#expect(!model.shouldRetainAfterList(at: observedAt.addingTimeInterval(15)))
|
|
}
|
|
|
|
@MainActor
|
|
@Test func `question card locally expired state enters terminal retention`() {
|
|
let expiresAt = Date(timeIntervalSince1970: 1500)
|
|
let model = OpenClawQuestionCardModel(record: questionRecord(expiresAtMs: 1_500_000))
|
|
|
|
#expect(model.observeLocalExpiry(at: expiresAt))
|
|
#expect(model.shouldRetainAfterList(at: expiresAt.addingTimeInterval(14)))
|
|
#expect(!model.shouldRetainAfterList(at: expiresAt.addingTimeInterval(15)))
|
|
}
|
|
|
|
@MainActor
|
|
@Test func `question card stores local answers in gateway record shape`() throws {
|
|
let model = OpenClawQuestionCardModel(record: questionRecord())
|
|
model.toggleOption(questionID: "meal", label: "Pizza")
|
|
model.markAnsweredLocally()
|
|
|
|
let data = try JSONEncoder().encode(model.record.answers)
|
|
let json = try #require(String(data: data, encoding: .utf8))
|
|
#expect(json.contains("\"meal\":{\"answers\":[\"Pizza\"]}"))
|
|
}
|