Files
hapi/ios/Packages/HapiKit/Tests/HapiClientTests/Stores/SessionExportTests.swift
T
weishuandClaude Opus 5.5 132d256171 feat(ios): fork, rewind and export conversations
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>
2026-10-28 18:00:00 +08:00

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")
}
}