mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-07 01:21:37 +00:00
* fix: list Claude background tasks from the snapshot instead of guessing
The status panel only had a count from `background_tasks_changed` and
reconstructed commands by regex-matching Bash results in loaded chat
history, so tasks whose start message was unloaded, auto-backgrounded
("moved to the background (ID: …)"), or non-Bash showed
"N running · exact command unavailable".
The hub now keeps the snapshot's tasks (id, type, description) on the
session alongside the count. The web panel lists them directly, using
the exact task ID to look up the Bash command when loaded and the
snapshot description otherwise.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web): only attribute a Bash command from its background launch result
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(native): accept backgroundTasks in strict session patch decoders
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
211 lines
9.7 KiB
Swift
211 lines
9.7 KiB
Swift
import Foundation
|
|
|
|
/// A `{version, value}` wrapper carried by structured `session-updated`
|
|
/// patches for `metadata` / `agentState` / `todos` / `teamState`.
|
|
///
|
|
/// The version is the only safe way to reject stale patches: the dual SSE
|
|
/// connections have no shared ordering, so `value` must be applied — and the
|
|
/// matching watermark stored — only when `version` is strictly greater than
|
|
/// the cached one (see `isNewerVersionedPatch(patchVersion:cachedVersion:)`).
|
|
/// Never assign the wrapper itself into a `Session`.
|
|
public struct VersionedValue<Value: Codable & Equatable & Sendable>: Codable, Equatable, Sendable {
|
|
public var version: Int
|
|
/// `nil` covers wire-`null` (explicit clear, e.g. team deletion).
|
|
public var value: Value?
|
|
|
|
public init(version: Int, value: Value?) {
|
|
self.version = version
|
|
self.value = value
|
|
}
|
|
}
|
|
|
|
/// A patch field whose wire type is `T | null`, where "key absent" and
|
|
/// "key present with null" mean different things.
|
|
///
|
|
/// `SessionPatch.model = nil` means the patch does not touch `model`;
|
|
/// `.null` means the patch explicitly clears it. Swift's synthesized Codable
|
|
/// (`decodeIfPresent`) conflates the two, so `SessionPatch` decodes these by
|
|
/// hand via `contains` + `decodeNil`.
|
|
public enum PatchField<Value: Codable & Equatable & Sendable>: Equatable, Sendable {
|
|
case null
|
|
case value(Value)
|
|
|
|
/// The value as it would appear after applying: `nil` for `.null`.
|
|
public var wireValue: Value? {
|
|
if case .value(let value) = self { return value }
|
|
return nil
|
|
}
|
|
}
|
|
|
|
/// A structured `session-updated` patch.
|
|
///
|
|
/// Mirrors `SessionPatchSchema` (`shared/src/schemas.ts:371-404`), which is
|
|
/// zod `.strict()`: unknown keys make decoding throw. That strictness is
|
|
/// load-bearing — `SessionUpdatedData` discriminates the
|
|
/// `Session | SessionPatch` union by attempting `Session` first and falling
|
|
/// back to the strict patch, so a malformed full session must not silently
|
|
/// half-decode as a lenient patch.
|
|
public struct SessionPatch: Equatable, Sendable {
|
|
public var active: Bool?
|
|
public var thinking: Bool?
|
|
/// Carried on the wire but NOT applied by `applySessionDetailPatch`
|
|
/// (mirroring `web/src/lib/sessionPatch.ts`).
|
|
public var activeTurnStartedAt: PatchField<Int>?
|
|
public var activeAt: Int?
|
|
public var updatedAt: Int?
|
|
public var metadata: VersionedValue<SessionMetadata>?
|
|
public var agentState: VersionedValue<AgentState>?
|
|
public var todos: VersionedValue<[TodoItem]>?
|
|
public var teamState: VersionedValue<JSONValue>?
|
|
public var model: PatchField<String>?
|
|
public var modelReasoningEffort: PatchField<String>?
|
|
public var effort: PatchField<String>?
|
|
public var serviceTier: PatchField<String>?
|
|
public var permissionMode: PermissionMode?
|
|
public var collaborationMode: CodexCollaborationMode?
|
|
public var copilotAgentMode: CopilotAgentMode?
|
|
public var backgroundTaskCount: Int?
|
|
/// Bare refetch trigger for `GET /api/sessions/:id/scratchlist`; never
|
|
/// applied onto the `Session` (which carries no such field).
|
|
public var scratchlistUpdatedAt: Int?
|
|
|
|
public init(
|
|
active: Bool? = nil,
|
|
thinking: Bool? = nil,
|
|
activeTurnStartedAt: PatchField<Int>? = nil,
|
|
activeAt: Int? = nil,
|
|
updatedAt: Int? = nil,
|
|
metadata: VersionedValue<SessionMetadata>? = nil,
|
|
agentState: VersionedValue<AgentState>? = nil,
|
|
todos: VersionedValue<[TodoItem]>? = nil,
|
|
teamState: VersionedValue<JSONValue>? = nil,
|
|
model: PatchField<String>? = nil,
|
|
modelReasoningEffort: PatchField<String>? = nil,
|
|
effort: PatchField<String>? = nil,
|
|
serviceTier: PatchField<String>? = nil,
|
|
permissionMode: PermissionMode? = nil,
|
|
collaborationMode: CodexCollaborationMode? = nil,
|
|
copilotAgentMode: CopilotAgentMode? = nil,
|
|
backgroundTaskCount: Int? = nil,
|
|
scratchlistUpdatedAt: Int? = nil
|
|
) {
|
|
self.active = active
|
|
self.thinking = thinking
|
|
self.activeTurnStartedAt = activeTurnStartedAt
|
|
self.activeAt = activeAt
|
|
self.updatedAt = updatedAt
|
|
self.metadata = metadata
|
|
self.agentState = agentState
|
|
self.todos = todos
|
|
self.teamState = teamState
|
|
self.model = model
|
|
self.modelReasoningEffort = modelReasoningEffort
|
|
self.effort = effort
|
|
self.serviceTier = serviceTier
|
|
self.permissionMode = permissionMode
|
|
self.collaborationMode = collaborationMode
|
|
self.copilotAgentMode = copilotAgentMode
|
|
self.backgroundTaskCount = backgroundTaskCount
|
|
self.scratchlistUpdatedAt = scratchlistUpdatedAt
|
|
}
|
|
}
|
|
|
|
extension SessionPatch: Codable {
|
|
private enum CodingKeys: String, CodingKey, CaseIterable {
|
|
case active
|
|
case thinking
|
|
case activeTurnStartedAt
|
|
case activeAt
|
|
case updatedAt
|
|
case metadata
|
|
case agentState
|
|
case todos
|
|
case teamState
|
|
case model
|
|
case modelReasoningEffort
|
|
case effort
|
|
case serviceTier
|
|
case permissionMode
|
|
case collaborationMode
|
|
case copilotAgentMode
|
|
case backgroundTaskCount
|
|
case scratchlistUpdatedAt
|
|
// Web-only status-panel list; accepted so the patch is not refetched.
|
|
case backgroundTasks
|
|
}
|
|
|
|
public init(from decoder: Decoder) throws {
|
|
// Mirror zod `.strict()`: any unknown key rejects the whole patch.
|
|
try rejectUnknownKeys(
|
|
in: decoder,
|
|
known: Set(CodingKeys.allCases.map(\.stringValue)),
|
|
payloadName: "SessionPatch"
|
|
)
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
active = try container.decodeIfPresent(Bool.self, forKey: .active)
|
|
thinking = try container.decodeIfPresent(Bool.self, forKey: .thinking)
|
|
activeTurnStartedAt = try Self.decodePatchField(Int.self, in: container, forKey: .activeTurnStartedAt)
|
|
activeAt = try container.decodeIfPresent(Int.self, forKey: .activeAt)
|
|
updatedAt = try container.decodeIfPresent(Int.self, forKey: .updatedAt)
|
|
metadata = try container.decodeIfPresent(VersionedValue<SessionMetadata>.self, forKey: .metadata)
|
|
agentState = try container.decodeIfPresent(VersionedValue<AgentState>.self, forKey: .agentState)
|
|
todos = try container.decodeIfPresent(VersionedValue<[TodoItem]>.self, forKey: .todos)
|
|
teamState = try container.decodeIfPresent(VersionedValue<JSONValue>.self, forKey: .teamState)
|
|
model = try Self.decodePatchField(String.self, in: container, forKey: .model)
|
|
modelReasoningEffort = try Self.decodePatchField(String.self, in: container, forKey: .modelReasoningEffort)
|
|
effort = try Self.decodePatchField(String.self, in: container, forKey: .effort)
|
|
serviceTier = try Self.decodePatchField(String.self, in: container, forKey: .serviceTier)
|
|
permissionMode = try container.decodeIfPresent(PermissionMode.self, forKey: .permissionMode)
|
|
collaborationMode = try container.decodeIfPresent(CodexCollaborationMode.self, forKey: .collaborationMode)
|
|
copilotAgentMode = try container.decodeIfPresent(CopilotAgentMode.self, forKey: .copilotAgentMode)
|
|
backgroundTaskCount = try container.decodeIfPresent(Int.self, forKey: .backgroundTaskCount)
|
|
scratchlistUpdatedAt = try container.decodeIfPresent(Int.self, forKey: .scratchlistUpdatedAt)
|
|
}
|
|
|
|
public func encode(to encoder: Encoder) throws {
|
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
|
try container.encodeIfPresent(active, forKey: .active)
|
|
try container.encodeIfPresent(thinking, forKey: .thinking)
|
|
try Self.encodePatchField(activeTurnStartedAt, in: &container, forKey: .activeTurnStartedAt)
|
|
try container.encodeIfPresent(activeAt, forKey: .activeAt)
|
|
try container.encodeIfPresent(updatedAt, forKey: .updatedAt)
|
|
try container.encodeIfPresent(metadata, forKey: .metadata)
|
|
try container.encodeIfPresent(agentState, forKey: .agentState)
|
|
try container.encodeIfPresent(todos, forKey: .todos)
|
|
try container.encodeIfPresent(teamState, forKey: .teamState)
|
|
try Self.encodePatchField(model, in: &container, forKey: .model)
|
|
try Self.encodePatchField(modelReasoningEffort, in: &container, forKey: .modelReasoningEffort)
|
|
try Self.encodePatchField(effort, in: &container, forKey: .effort)
|
|
try Self.encodePatchField(serviceTier, in: &container, forKey: .serviceTier)
|
|
try container.encodeIfPresent(permissionMode, forKey: .permissionMode)
|
|
try container.encodeIfPresent(collaborationMode, forKey: .collaborationMode)
|
|
try container.encodeIfPresent(copilotAgentMode, forKey: .copilotAgentMode)
|
|
try container.encodeIfPresent(backgroundTaskCount, forKey: .backgroundTaskCount)
|
|
try container.encodeIfPresent(scratchlistUpdatedAt, forKey: .scratchlistUpdatedAt)
|
|
}
|
|
|
|
private static func decodePatchField<Value: Codable & Equatable & Sendable>(
|
|
_ type: Value.Type,
|
|
in container: KeyedDecodingContainer<CodingKeys>,
|
|
forKey key: CodingKeys
|
|
) throws -> PatchField<Value>? {
|
|
guard container.contains(key) else { return nil }
|
|
if try container.decodeNil(forKey: key) { return .null }
|
|
return .value(try container.decode(Value.self, forKey: key))
|
|
}
|
|
|
|
private static func encodePatchField<Value: Codable & Equatable & Sendable>(
|
|
_ field: PatchField<Value>?,
|
|
in container: inout KeyedEncodingContainer<CodingKeys>,
|
|
forKey key: CodingKeys
|
|
) throws {
|
|
guard let field else { return }
|
|
switch field {
|
|
case .null:
|
|
try container.encodeNil(forKey: key)
|
|
case .value(let value):
|
|
try container.encode(value, forKey: key)
|
|
}
|
|
}
|
|
}
|