diff --git a/companion/src/routes.ts b/companion/src/routes.ts index a7fa7cb6a3..cd2018b549 100644 --- a/companion/src/routes.ts +++ b/companion/src/routes.ts @@ -110,6 +110,17 @@ const ALLOWED: ReadonlyArray<{ method: string; path: RegExp }> = [ // recent activity. No settings, no transcript — read on open and on // pull-to-refresh. { method: "GET", path: /^\/api\/bots\/[\w-]+\/overview$/ }, + // Read-only: what the bot did, with the outcome, built from logs that + // already exist (server/activity.ts). No settings reachable through it. + { method: "GET", path: /^\/api\/bots\/[\w-]+\/activity$/ }, + // The section's shared team memory: people, places, decisions and terms. + // Reading, adding, answering a proposal, editing a detail, and removing + // are all content edits, not execution policy — the same line the profile + // and sidebar-section routes draw. The section rides as a query string. + { method: "GET", path: /^\/api\/team-memory$/ }, + { method: "POST", path: /^\/api\/team-memory$/ }, + { method: "PATCH", path: /^\/api\/team-memory\/[\w-]+$/ }, + { method: "DELETE", path: /^\/api\/team-memory\/[\w-]+$/ }, // Paired-safe profile subset. The harness route itself rejects fields // outside identity, standing instructions (soul, byte-capped), avatar, // notifications, and voice preferences. diff --git a/companion/test/routes.test.ts b/companion/test/routes.test.ts index 5f5208067c..5dee04ced8 100644 --- a/companion/test/routes.test.ts +++ b/companion/test/routes.test.ts @@ -54,6 +54,14 @@ describe("what the app may do", () => { ["POST", "/api/bots/bot_123/always-allow"], ["POST", "/api/bots/bot_123/messages/msg_2/edit"], ["GET", "/api/bots/bot_123/overview"], + // Read-only: what the bot did, with the outcome (server/activity.ts). + ["GET", "/api/bots/bot_123/activity"], + // The section's shared team memory: read it, add an entry, answer or + // edit one, remove one. Content, not execution policy. + ["GET", "/api/team-memory"], + ["POST", "/api/team-memory"], + ["PATCH", "/api/team-memory/entry_1"], + ["DELETE", "/api/team-memory/entry_1"], ["POST", "/api/bots/bot_123/active-branch"], ["POST", "/api/bots/bot_123/tasks"], ["POST", "/api/bots/bot_123/tasks/th_1"], diff --git a/ios/App/AgentProfileView.swift b/ios/App/AgentProfileView.swift index 6afe69ea72..ab482005d0 100644 --- a/ios/App/AgentProfileView.swift +++ b/ios/App/AgentProfileView.swift @@ -228,6 +228,16 @@ struct AgentProfileView: View { } label: { Label("What this bot does", systemImage: "list.bullet.rectangle") } + NavigationLink { + BotActivityView(bot: current) + } label: { + Label("Activity", systemImage: "checklist") + } + NavigationLink { + TeamMemoryView(section: current.section ?? "") + } label: { + Label("Team memory", systemImage: "brain") + } TextField("Name", text: $name) .textInputAutocapitalization(.words) TextField("Title", text: $title) diff --git a/ios/App/BotActivityView.swift b/ios/App/BotActivityView.swift new file mode 100644 index 0000000000..8ac59af3b4 --- /dev/null +++ b/ios/App/BotActivityView.swift @@ -0,0 +1,145 @@ +import CompanionCore +import SwiftUI + +/// What one bot did, newest first, grouped by day: every tool it used and +/// every approval it asked for, each with the outcome. The phone twin of the +/// desktop's Activity panel; read-only, like the overview beside it. +struct BotActivityView: View { + let bot: Bot + @EnvironmentObject private var session: Session + @State private var rows: [ActivityRow]? + @State private var loading = false + @State private var failed = false + + private static let isoParser: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + private struct Day: Identifiable { + let id: String + let label: String + let rows: [ActivityRow] + } + + private var days: [Day] { + guard let rows else { return [] } + let calendar = Calendar.current + var result: [Day] = [] + for row in rows { + let date = Self.isoParser.date(from: row.at) ?? ISO8601DateFormatter().date(from: row.at) ?? Date() + let key = calendar.startOfDay(for: date) + let id = "\(key.timeIntervalSince1970)" + if let last = result.last, last.id == id { + result[result.count - 1] = Day(id: id, label: last.label, rows: last.rows + [row]) + } else { + let label = calendar.isDateInToday(date) ? String(localized: "Today") + : calendar.isDateInYesterday(date) ? String(localized: "Yesterday") + : date.formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated)) + result.append(Day(id: id, label: label, rows: [row])) + } + } + return result + } + + var body: some View { + List { + if let rows, rows.isEmpty { + Section { + Text("Nothing yet. Once \(bot.name) runs a tool or asks for an approval, it shows up here.") + .foregroundStyle(.secondary) + } + } + ForEach(days) { day in + Section(day.label) { + ForEach(Array(day.rows.enumerated()), id: \.offset) { _, row in + ActivityRowView(row: row) + } + } + } + if failed { + Section { + ContentUnavailableView("Couldn't load", systemImage: "wifi.exclamationmark") + } + } + } + .navigationTitle("\(bot.name) activity") + .overlay { if loading && rows == nil { ProgressView() } } + .task(id: session.connection?.id) { + rows = nil + failed = false + await load() + } + .refreshable { await load() } + } + + private func load() async { + let connectionID = session.connection?.id + loading = true + defer { + if !Task.isCancelled, session.connection?.id == connectionID { loading = false } + } + let loaded = await session.botActivity(for: bot) + guard !Task.isCancelled, session.connection?.id == connectionID else { return } + if let loaded { + rows = loaded + failed = false + } else { + failed = true + } + } +} + +private struct ActivityRowView: View { + let row: ActivityRow + + private var time: String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let date = formatter.date(from: row.at) ?? ISO8601DateFormatter().date(from: row.at) + return date.map { $0.formatted(date: .omitted, time: .shortened) } ?? "" + } + + private var chip: (text: LocalizedStringKey, color: Color) { + switch row.outcome { + case "ran", "allowed": return ("\(row.outcome == "ran" ? "Ran" : "Allowed")", .green) + case "failed", "denied": return ("\(row.outcome == "failed" ? "Failed" : "Denied")", .red) + case "running": return ("Running", .accentColor) + case "waiting": return ("Needs you", .orange) + default: return ("\(row.outcome)", .secondary) + } + } + + var body: some View { + HStack(alignment: .top, spacing: 10) { + Text(time) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + .frame(width: 44, alignment: .leading) + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 4) { + if let app = row.app { + Text(app).fontWeight(.medium) + Text("·").foregroundStyle(.secondary) + } + Text(row.label).lineLimit(1) + } + if let summary = row.summary, !summary.isEmpty { + Text(summary) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } + Spacer(minLength: 6) + Text(chip.text) + .font(.caption2.weight(.medium)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(chip.color.opacity(0.15), in: Capsule()) + .foregroundStyle(chip.color) + } + .padding(.vertical, 2) + } +} diff --git a/ios/App/Session.swift b/ios/App/Session.swift index ad51311feb..a01b897e5a 100644 --- a/ios/App/Session.swift +++ b/ios/App/Session.swift @@ -1676,6 +1676,55 @@ final class Session: ObservableObject { } } + /// What the bot did, with the outcome. Read-only, like the overview. + func botActivity(for bot: Bot) async -> [ActivityRow]? { + guard let client else { return nil } + let connectionID = connection?.id + do { + let rows = try await client.activity(botId: bot.id) + guard !Task.isCancelled, connection?.id == connectionID else { return nil } + return rows + } catch { + guard !Task.isCancelled, connection?.id == connectionID else { return nil } + guard !(error is CancellationError), (error as? URLError)?.code != .cancelled else { return nil } + actionError = error.localizedDescription + return nil + } + } + + // MARK: - Team memory + + /// The section's shared people, places, decisions and terms. + func teamMemory(section: String) async -> TeamMemoryPage? { + guard let client else { return nil } + let connectionID = connection?.id + do { + let page = try await client.teamMemory(section: section) + guard !Task.isCancelled, connection?.id == connectionID else { return nil } + return page + } catch { + guard !Task.isCancelled, connection?.id == connectionID else { return nil } + guard !(error is CancellationError), (error as? URLError)?.code != .cancelled else { return nil } + actionError = error.localizedDescription + return nil + } + } + + /// One edit, and the page as it is afterwards; nil when it failed, with + /// the failure already shown. + func editTeamMemory(_ body: (CompanionClient) async throws -> [TeamMemoryEntry]) async -> [TeamMemoryEntry]? { + guard let client else { return nil } + do { + return try await body(client) + } catch let error as APIError where error.isUnauthorized { + status = .unauthorized + return nil + } catch { + actionError = error.localizedDescription + return nil + } + } + // MARK: - Routines func loadRoutines() async -> (routines: [Routine], runs: [RoutineRun]) { diff --git a/ios/App/TeamMemoryView.swift b/ios/App/TeamMemoryView.swift new file mode 100644 index 0000000000..3394e60986 --- /dev/null +++ b/ios/App/TeamMemoryView.swift @@ -0,0 +1,177 @@ +import CompanionCore +import SwiftUI + +/// The people, places, decisions and terms every bot in a section shares. +/// Bots add what they learn (places and terms land at once; people and +/// decisions wait here for a tap); the person answers, edits, adds, and +/// removes. The phone twin of Team map → Memory on the desktop. +struct TeamMemoryView: View { + let section: String + @EnvironmentObject private var session: Session + @State private var page: TeamMemoryPage? + @State private var loading = false + @State private var failed = false + @State private var busyID: String? + @State private var adding = false + @State private var draftKind = "term" + @State private var draftName = "" + @State private var draftDetail = "" + + private static let kinds: [(kind: String, title: LocalizedStringKey)] = [ + ("person", "People"), ("place", "Places"), ("decision", "Decisions"), ("term", "Terms"), + ] + + private var entries: [TeamMemoryEntry] { page?.entries ?? [] } + private var proposed: [TeamMemoryEntry] { entries.filter { $0.status == "proposed" } } + + var body: some View { + List { + if !proposed.isEmpty { + Section("Waiting for you") { + ForEach(proposed) { entry in + VStack(alignment: .leading, spacing: 6) { + EntryLine(entry: entry) + HStack { + Button("Remember", systemImage: "checkmark") { + Task { await answer(entry, remember: true) } + } + .buttonStyle(.borderedProminent) + Button("Skip") { + Task { await answer(entry, remember: false) } + } + .buttonStyle(.bordered) + } + .disabled(busyID == entry.id) + } + } + } + } + if let page, page.entries.filter({ $0.status == "accepted" }).isEmpty, proposed.isEmpty { + Section { + Text("Nothing shared yet. Bots add entries as they learn who is who and where things live, or add one below.") + .foregroundStyle(.secondary) + } + } + ForEach(Self.kinds, id: \.kind) { kind in + let rows = entries.filter { $0.status == "accepted" && $0.kind == kind.kind } + if !rows.isEmpty { + Section(kind.title) { + ForEach(rows) { entry in + EntryLine(entry: entry) + .swipeActions(edge: .trailing) { + Button(role: .destructive) { + Task { await remove(entry) } + } label: { + Label("Remove", systemImage: "trash") + } + } + } + } + } + } + Section { + if adding { + Picker("Kind", selection: $draftKind) { + Text("Person").tag("person") + Text("Place").tag("place") + Text("Decision").tag("decision") + Text("Term").tag("term") + } + TextField("Name", text: $draftName) + TextField("What every bot should know about it", text: $draftDetail, axis: .vertical) + .lineLimit(2...4) + HStack { + Button("Add") { Task { await add() } } + .buttonStyle(.borderedProminent) + .disabled(busyID == "new" || draftName.trimmingCharacters(in: .whitespaces).isEmpty || draftDetail.trimmingCharacters(in: .whitespaces).isEmpty) + Button("Cancel") { adding = false } + .buttonStyle(.bordered) + } + } else { + Button("Add an entry", systemImage: "plus") { adding = true } + } + } + if failed { + Section { + ContentUnavailableView("Couldn't load", systemImage: "wifi.exclamationmark") + } + } + } + .navigationTitle(page.map { "\($0.label) team memory" } ?? "Team memory") + .overlay { if loading && page == nil { ProgressView() } } + .task(id: session.connection?.id) { + page = nil + failed = false + await load() + } + .refreshable { await load() } + } + + private func load() async { + let connectionID = session.connection?.id + loading = true + defer { + if !Task.isCancelled, session.connection?.id == connectionID { loading = false } + } + let loaded = await session.teamMemory(section: section) + guard !Task.isCancelled, session.connection?.id == connectionID else { return } + if let loaded { + page = loaded + failed = false + } else { + failed = true + } + } + + private func apply(_ entries: [TeamMemoryEntry]?) { + guard let entries, var current = page else { return } + current.entries = entries + page = current + } + + private func answer(_ entry: TeamMemoryEntry, remember: Bool) async { + busyID = entry.id + defer { busyID = nil } + apply(await session.editTeamMemory { try await $0.answerTeamMemory(section: section, id: entry.id, remember: remember) }) + } + + private func remove(_ entry: TeamMemoryEntry) async { + busyID = entry.id + defer { busyID = nil } + apply(await session.editTeamMemory { try await $0.removeTeamMemory(section: section, id: entry.id) }) + } + + private func add() async { + busyID = "new" + defer { busyID = nil } + let name = draftName.trimmingCharacters(in: .whitespaces) + let detail = draftDetail.trimmingCharacters(in: .whitespaces) + let result = await session.editTeamMemory { try await $0.addTeamMemory(section: section, kind: draftKind, name: name, detail: detail) } + if result != nil { + draftName = "" + draftDetail = "" + adding = false + } + apply(result) + } +} + +private struct EntryLine: View { + let entry: TeamMemoryEntry + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 4) { + Text(entry.name).fontWeight(.medium) + if !entry.aliases.isEmpty { + Text("(also \(entry.aliases.joined(separator: ", ")))") + .foregroundStyle(.secondary) + } + } + Text(entry.detail) + Text("\(entry.source.botName.isEmpty ? "you" : entry.source.botName) · \(Date(timeIntervalSince1970: entry.updatedAt / 1_000).formatted(date: .abbreviated, time: .omitted))") + .font(.caption) + .foregroundStyle(.secondary) + } + } +} diff --git a/ios/Sources/CompanionCore/Client.swift b/ios/Sources/CompanionCore/Client.swift index 633078610c..d3e4b63f11 100644 --- a/ios/Sources/CompanionCore/Client.swift +++ b/ios/Sources/CompanionCore/Client.swift @@ -1045,6 +1045,57 @@ public struct CompanionClient: Sendable { return try await send(try makeRequest("GET", "/api/bots/\(botId)/overview"), as: BotOverview.self) } + /// What the bot did, newest first: every tool it used and every approval + /// it asked for, with the outcome. Read-only, like the overview. + public func activity(botId: String, limit: Int = 200) async throws -> [ActivityRow] { + guard Self.validRouteID(botId) else { throw APIError.badURL } + let query = [URLQueryItem(name: "limit", value: String(limit))] + return try await send(try makeRequest("GET", "/api/bots/\(botId)/activity", query: query), as: ActivityPage.self).rows + } + + // MARK: - Team memory + + /// The section's shared people, places, decisions and terms. The + /// section is a query parameter, empty for General, and always sent. + public func teamMemory(section: String) async throws -> TeamMemoryPage { + try await send(try makeRequest("GET", "/api/team-memory", query: [URLQueryItem(name: "section", value: section)]), as: TeamMemoryPage.self) + } + + /// Add an entry by hand. The person's own entry never waits on the person. + public func addTeamMemory(section: String, kind: String, name: String, detail: String) async throws -> [TeamMemoryEntry] { + try await send( + try makeRequest("POST", "/api/team-memory", query: [URLQueryItem(name: "section", value: section)], body: ["kind": kind, "name": name, "detail": detail]), + as: TeamMemoryEdit.self + ).entries + } + + /// Answer a proposal: remember it, or drop it. + public func answerTeamMemory(section: String, id: String, remember: Bool) async throws -> [TeamMemoryEntry] { + guard Self.validRouteID(id) else { throw APIError.badURL } + let query = [URLQueryItem(name: "section", value: section)] + if remember { + return try await send(try makeRequest("PATCH", "/api/team-memory/\(id)", query: query, body: ["accept": true]), as: TeamMemoryEdit.self).entries + } + return try await send(try makeRequest("DELETE", "/api/team-memory/\(id)", query: query), as: TeamMemoryEdit.self).entries + } + + /// Change what an entry says. Editing a proposal accepts it. + public func updateTeamMemory(section: String, id: String, detail: String) async throws -> [TeamMemoryEntry] { + guard Self.validRouteID(id) else { throw APIError.badURL } + return try await send( + try makeRequest("PATCH", "/api/team-memory/\(id)", query: [URLQueryItem(name: "section", value: section)], body: ["detail": detail]), + as: TeamMemoryEdit.self + ).entries + } + + public func removeTeamMemory(section: String, id: String) async throws -> [TeamMemoryEntry] { + guard Self.validRouteID(id) else { throw APIError.badURL } + return try await send( + try makeRequest("DELETE", "/api/team-memory/\(id)", query: [URLQueryItem(name: "section", value: section)]), + as: TeamMemoryEdit.self + ).entries + } + // MARK: - Doing /// Make a new bot. The harness picks its name, colour and greeting — the diff --git a/ios/Sources/CompanionCore/Models.swift b/ios/Sources/CompanionCore/Models.swift index 1962f334a9..d1dc1ed7e3 100644 --- a/ios/Sources/CompanionCore/Models.swift +++ b/ios/Sources/CompanionCore/Models.swift @@ -312,6 +312,67 @@ public struct BotOverview: Codable, Hashable, Sendable { public var recent: [BotOverviewRecent] } +/// One line of a bot's activity log: what ran, in words, and how it ended. +/// Built on the computer from logs that already exist (server/activity.ts); +/// the phone only reads it. +public struct ActivityRow: Codable, Hashable, Sendable { + /// ISO 8601, when it started (a tool) or was asked (a request) + public var at: String + public var threadId: String + public var turnId: String? + public var requestId: String? + /// the raw tool name + public var tool: String + /// the connected app or surface it touched, when there is one + public var app: String? + /// the action, in words + public var label: String + /// the arguments the decision log recorded, already redacted + public var summary: String? + /// ran | failed | running | allowed | denied | waiting + public var outcome: String +} + +public struct ActivityPage: Codable, Hashable, Sendable { + public var rows: [ActivityRow] +} + +public struct TeamMemorySource: Codable, Hashable, Sendable { + public var botId: String + public var botName: String + public var threadId: String + /// epoch milliseconds + public var at: Double +} + +/// One thing every bot in a section shares: a person, a place, a decision, +/// or a term. `proposed` waits for the person; `accepted` rides the prompt. +public struct TeamMemoryEntry: Codable, Hashable, Identifiable, Sendable { + public var id: String + /// person | place | decision | term + public var kind: String + public var name: String + public var detail: String + public var aliases: [String] + /// accepted | proposed + public var status: String + public var source: TeamMemorySource + /// epoch milliseconds + public var updatedAt: Double +} + +public struct TeamMemoryPage: Codable, Hashable, Sendable { + public var section: String + public var label: String + public var entries: [TeamMemoryEntry] +} + +/// What an edit answers with: the edited entry and the whole page. +public struct TeamMemoryEdit: Codable, Hashable, Sendable { + public var entry: TeamMemoryEntry? + public var entries: [TeamMemoryEntry] +} + public struct GroupResponder: Codable, Hashable, Sendable { public var kind: String public var botId: String? diff --git a/ios/Tests/CompanionCoreTests/ActivityClientTests.swift b/ios/Tests/CompanionCoreTests/ActivityClientTests.swift new file mode 100644 index 0000000000..a8f707ed21 --- /dev/null +++ b/ios/Tests/CompanionCoreTests/ActivityClientTests.swift @@ -0,0 +1,146 @@ +import Foundation +import XCTest +@testable import CompanionCore + +private final class ActivityRequestStub: URLProtocol { + static var responseBody = Data() + static var statusCode = 200 + static var capturedRequest: URLRequest? + static var capturedBody: Data? + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + Self.capturedRequest = request + Self.capturedBody = request.httpBody ?? Self.readBody(from: request) + let response = HTTPURLResponse( + url: request.url!, + statusCode: Self.statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + )! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Self.responseBody) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} + + private static func readBody(from request: URLRequest) -> Data? { + guard let stream = request.httpBodyStream else { return nil } + stream.open() + defer { stream.close() } + var data = Data() + var buffer = [UInt8](repeating: 0, count: 1_024) + while stream.hasBytesAvailable { + let count = stream.read(&buffer, maxLength: buffer.count) + guard count > 0 else { break } + data.append(buffer, count: count) + } + return data + } +} + +/// The activity log and team memory calls: the paths and queries the sidecar +/// allowlist expects, and the bodies the harness routes parse. +final class ActivityClientTests: XCTestCase { + private var session: URLSession! + private var client: CompanionClient! + + override func setUp() { + super.setUp() + ActivityRequestStub.responseBody = Data() + ActivityRequestStub.statusCode = 200 + ActivityRequestStub.capturedRequest = nil + ActivityRequestStub.capturedBody = nil + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [ActivityRequestStub.self] + session = URLSession(configuration: configuration) + client = CompanionClient( + connection: Connection(name: "Test", host: "127.0.0.1", port: 8810), + token: "paired-token", + session: session + ) + } + + override func tearDown() { + session?.invalidateAndCancel() + session = nil + client = nil + super.tearDown() + } + + private func body() throws -> [String: Any] { + let data = try XCTUnwrap(ActivityRequestStub.capturedBody) + return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + } + + func testReadsTheActivityLogWithALimit() async throws { + ActivityRequestStub.responseBody = Data(#"{"rows":[{"at":"2026-09-07T09:00:00.000Z","threadId":"t1","tool":"GMAIL_SEND_EMAIL","app":"Gmail","label":"Send email","summary":"to finance@","outcome":"waiting"}]}"#.utf8) + + let rows = try await client.activity(botId: "bot_1", limit: 50) + + let url = try XCTUnwrap(ActivityRequestStub.capturedRequest?.url) + XCTAssertEqual(url.path, "/api/bots/bot_1/activity") + XCTAssertEqual(url.query, "limit=50") + XCTAssertEqual(rows.count, 1) + XCTAssertEqual(rows[0].app, "Gmail") + XCTAssertEqual(rows[0].outcome, "waiting") + } + + func testRefusesABotIdThatIsNotARouteId() async { + do { + _ = try await client.activity(botId: "../secrets") + XCTFail("a path-shaped id must never reach the wire") + } catch { + XCTAssertNil(ActivityRequestStub.capturedRequest) + } + } + + func testReadsTeamMemoryForTheGeneralSection() async throws { + ActivityRequestStub.responseBody = Data(#"{"section":"","label":"General","entries":[]}"#.utf8) + + let page = try await client.teamMemory(section: "") + + let url = try XCTUnwrap(ActivityRequestStub.capturedRequest?.url) + XCTAssertEqual(url.path, "/api/team-memory") + // the harness requires the parameter even when it is empty + XCTAssertEqual(url.query, "section=") + XCTAssertEqual(page.label, "General") + } + + func testRemembersAProposalWithAnAcceptPatch() async throws { + ActivityRequestStub.responseBody = Data(#"{"entries":[]}"#.utf8) + + _ = try await client.answerTeamMemory(section: "Work", id: "entry_1", remember: true) + + let request = try XCTUnwrap(ActivityRequestStub.capturedRequest) + XCTAssertEqual(request.httpMethod, "PATCH") + XCTAssertEqual(request.url?.path, "/api/team-memory/entry_1") + XCTAssertEqual(request.url?.query, "section=Work") + XCTAssertEqual(try body()["accept"] as? Bool, true) + } + + func testSkipsAProposalWithADelete() async throws { + ActivityRequestStub.responseBody = Data(#"{"entries":[]}"#.utf8) + + _ = try await client.answerTeamMemory(section: "", id: "entry_1", remember: false) + + let request = try XCTUnwrap(ActivityRequestStub.capturedRequest) + XCTAssertEqual(request.httpMethod, "DELETE") + XCTAssertEqual(request.url?.path, "/api/team-memory/entry_1") + } + + func testAddsAnEntryByHand() async throws { + ActivityRequestStub.responseBody = Data(#"{"entries":[{"id":"e1","kind":"term","name":"MCHQ","detail":"MissionControlHQ","aliases":[],"status":"accepted","source":{"botId":"","botName":"you","threadId":"","at":1},"updatedAt":1}]}"#.utf8) + + let entries = try await client.addTeamMemory(section: "", kind: "term", name: "MCHQ", detail: "MissionControlHQ") + + let request = try XCTUnwrap(ActivityRequestStub.capturedRequest) + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertEqual(request.url?.path, "/api/team-memory") + XCTAssertEqual(try body()["kind"] as? String, "term") + XCTAssertEqual(entries.first?.name, "MCHQ") + } +} diff --git a/ios/Tests/CompanionCoreTests/DecodingTests.swift b/ios/Tests/CompanionCoreTests/DecodingTests.swift index 0945fa20af..046460a4be 100644 --- a/ios/Tests/CompanionCoreTests/DecodingTests.swift +++ b/ios/Tests/CompanionCoreTests/DecodingTests.swift @@ -56,6 +56,27 @@ final class DecodingTests: XCTestCase { XCTAssertNil(fleet.bots.first?.hasMore) } + func testDecodesAnActivityPage() throws { + // A fixture harness has run no tools, so the page is empty; the shape + // is what matters, and an empty list must decode, not fail. + let page = try decode(ActivityPage.self, "bot-activity") + XCTAssertEqual(page.rows, []) + } + + func testDecodesTeamMemory() throws { + let page = try decode(TeamMemoryPage.self, "team-memory") + XCTAssertEqual(page.section, "") + XCTAssertEqual(page.label, "General") + XCTAssertEqual(page.entries.map(\.kind), ["person", "place", "decision", "term"]) + let ada = try XCTUnwrap(page.entries.first) + XCTAssertEqual(ada.name, "Ada Lovelace") + XCTAssertEqual(ada.aliases, ["Ada"]) + XCTAssertEqual(ada.status, "accepted") + // the person's own entries carry no bot + XCTAssertEqual(ada.source.botName, "you") + XCTAssertGreaterThan(ada.updatedAt, 0) + } + func testDecodesABotOverview() throws { let overview = try decode(BotOverview.self, "bot-overview") XCTAssertEqual(overview.who.name, "Kiwi") diff --git a/ios/Tests/CompanionCoreTests/Fixtures/bot-activity.json b/ios/Tests/CompanionCoreTests/Fixtures/bot-activity.json new file mode 100644 index 0000000000..c1882d70c5 --- /dev/null +++ b/ios/Tests/CompanionCoreTests/Fixtures/bot-activity.json @@ -0,0 +1,3 @@ +{ + "rows": [] +} diff --git a/ios/Tests/CompanionCoreTests/Fixtures/team-memory.json b/ios/Tests/CompanionCoreTests/Fixtures/team-memory.json new file mode 100644 index 0000000000..a44406c537 --- /dev/null +++ b/ios/Tests/CompanionCoreTests/Fixtures/team-memory.json @@ -0,0 +1,68 @@ +{ + "section": "", + "label": "General", + "entries": [ + { + "id": "2cd370f0-4533-49d4-925d-df3e155990e3", + "kind": "person", + "name": "Ada Lovelace", + "detail": "Runs the team", + "aliases": [ + "Ada" + ], + "status": "accepted", + "source": { + "botId": "", + "botName": "you", + "threadId": "", + "at": 1788844887367 + }, + "updatedAt": 1788844887371 + }, + { + "id": "5586605e-03ae-409e-965d-13fc3baee4f9", + "kind": "place", + "name": "Launch plan", + "detail": "Notion, Marketing space", + "aliases": [], + "status": "accepted", + "source": { + "botId": "", + "botName": "you", + "threadId": "", + "at": 1788844887376 + }, + "updatedAt": 1788844887376 + }, + { + "id": "966bf5d2-c4e6-450a-9ef9-1fb002a3df5d", + "kind": "decision", + "name": "Ship Android first", + "detail": "Decided in the Monday sync", + "aliases": [], + "status": "accepted", + "source": { + "botId": "", + "botName": "you", + "threadId": "", + "at": 1788844887381 + }, + "updatedAt": 1788844887385 + }, + { + "id": "25296b34-1b2a-4fc4-be62-1f34d8a967e3", + "kind": "term", + "name": "MCHQ", + "detail": "MissionControlHQ, the old name", + "aliases": [], + "status": "accepted", + "source": { + "botId": "", + "botName": "you", + "threadId": "", + "at": 1788844887389 + }, + "updatedAt": 1788844887389 + } + ] +} diff --git a/scripts/capture-companion-fixtures.mjs b/scripts/capture-companion-fixtures.mjs index 184c569e3b..25f61bbe37 100644 --- a/scripts/capture-companion-fixtures.mjs +++ b/scripts/capture-companion-fixtures.mjs @@ -389,6 +389,31 @@ async function main() { if (overview.status !== 200) throw new Error(`could not read Kiwi's overview: ${JSON.stringify(overview.body)}`); write("bot-overview", overview.body); + // The bot's activity log. A fixture harness has no tool runs, so the rows + // are empty; the shape of the page is what both phone suites pin. + const activity = await json(`${SIDECAR}/api/bots/${kiwi.id}/activity?limit=50`, asDevice()); + if (activity.status !== 200) throw new Error(`could not read Kiwi's activity: ${JSON.stringify(activity.body)}`); + write("bot-activity", activity.body); + + // The General section's team memory, with one of each kind the person can + // add by hand, so a phone decodes every field an entry carries. + for (const entry of [ + { kind: "person", name: "Ada Lovelace", detail: "Runs the team", aliases: ["Ada"] }, + { kind: "place", name: "Launch plan", detail: "Notion, Marketing space" }, + { kind: "decision", name: "Ship Android first", detail: "Decided in the Monday sync" }, + { kind: "term", name: "MCHQ", detail: "MissionControlHQ, the old name" }, + ]) { + const added = await json(`${SIDECAR}/api/team-memory?section=`, asDevice({ + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(entry), + })); + if (added.status !== 201) throw new Error(`could not add team memory: ${JSON.stringify(added.body)}`); + } + const memory = await json(`${SIDECAR}/api/team-memory?section=`, asDevice()); + if (memory.status !== 200) throw new Error(`could not read team memory: ${JSON.stringify(memory.body)}`); + write("team-memory", memory.body); + console.log("\nnot captured: options-card.json — needs a real approval from a real turn"); }