mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-07 01:21:37 +00:00
The hub's fork, rewind and export routes had no iOS UI. The chat's menu now offers: - Fork Conversation: when the agent advertises forkCurrent, the session is live and no Codex turn is running (the web gate). The new session opens in place of this chat. - Rewind or Fork…: a sheet listing loaded user messages that have a native point in metadata.conversationHistoryPoints. Each row offers a confirmed Fork from Here or Rewind to Here, gated like web: forks need an idle turn except on Codex, and rewinds always need one. The actions live in a sheet, not on the transcript bubbles, because those keep text selection and cached row heights. After a rewind the tail resyncs with a history reset. An ambiguous native boundary (409 ambiguous_native_boundary_fork_safe) offers a fork from the same point instead. - Export Conversation…: saves the hub's lossless JSON payload through the Files save panel, using the web download's filename. A large export asks before retrying with force; a 413 explains the limit. Markdown export stays web-only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
56 lines
2.5 KiB
Swift
56 lines
2.5 KiB
Swift
import Foundation
|
|
@testable import HapiClient
|
|
import HapiProtocol
|
|
import Testing
|
|
|
|
/// `GET /sessions/:id/export`: the web download's filename and the hub's
|
|
/// warning / too-large answers.
|
|
@MainActor
|
|
@Suite("Session export")
|
|
struct SessionExportTests {
|
|
@Test func payloadBecomesAPrettyJsonFileNamedLikeTheWebDownload() async throws {
|
|
let performer = RecordingPerformer()
|
|
let api = try makeStoreAPIClient(performer: performer)
|
|
await performer.enqueue(json: """
|
|
{"schemaVersion":2,"exportedAt":1793145600000,"scratchlist":[],
|
|
"session":{"id":"abcdef123456","metadata":{"name":"Fix login: bug #2","path":"/repo/app"}},
|
|
"messages":[{"id":"m1"},{"id":"m2"}]}
|
|
""")
|
|
guard case .file(let file) = try await api.exportSession(sessionId: "abcdef123456") else {
|
|
Issue.record("expected a file")
|
|
return
|
|
}
|
|
#expect(file.filename == "fix-login-bug-2-abcdef12-2026-10-28.json")
|
|
#expect(file.messageCount == 2)
|
|
let text = String(decoding: file.data, as: UTF8.self)
|
|
#expect(text.hasSuffix("}\n"))
|
|
#expect(text.contains("\"path\" : \"/repo/app\""))
|
|
let path = await performer.requests.first?.url?.path
|
|
#expect(path == "/api/sessions/abcdef123456/export")
|
|
}
|
|
|
|
@Test func warningAndTooLargeAnswers() async throws {
|
|
let performer = RecordingPerformer()
|
|
let api = try makeStoreAPIClient(performer: performer)
|
|
await performer.enqueue(json: #"{"type":"warning","count":25000,"limit":20000,"estimatedBytes":9000}"#)
|
|
#expect(try await api.exportSession(sessionId: "s1") == .warning(count: 25000, limit: 20000, estimatedBytes: 9000))
|
|
|
|
await performer.enqueue(
|
|
status: 413,
|
|
json: #"{"type":"too-large","error":"x","code":"session_export_too_large","count":9,"estimatedBytes":200,"maxBytes":100}"#
|
|
)
|
|
#expect(try await api.exportSession(sessionId: "s1", force: true) == .tooLarge(count: 9, estimatedBytes: 200, maxBytes: 100))
|
|
let query = await performer.requests.last?.url?.query
|
|
#expect(query == "force=true")
|
|
}
|
|
|
|
@Test func untitledSessionsFallBackToThePathTail() throws {
|
|
let data = Data(#"{"exportedAt":0,"session":{"id":"zz","metadata":{"path":"/w/中文 项目"}},"messages":[]}"#.utf8)
|
|
guard case .file(let file) = try SessionExportFormatting.result(from: data) else {
|
|
Issue.record("expected a file")
|
|
return
|
|
}
|
|
#expect(file.filename == "中文-项目-zz-1970-01-01.json")
|
|
}
|
|
}
|