diff --git a/e2e/app.smoke.spec.ts b/e2e/app.smoke.spec.ts index da248558..c7c657a5 100644 --- a/e2e/app.smoke.spec.ts +++ b/e2e/app.smoke.spec.ts @@ -21,6 +21,15 @@ test('preserves the local demo routes alongside the default app', async ({ page await expect(page).toHaveURL('http://127.0.0.1:4174/?demo=minimal'); await expect(page.getByText('Chats', { exact: true })).toBeVisible(); await expect(page.getByText('Familiars', { exact: true })).toBeVisible(); + + await page.goto('/?demo=familiars-reads'); + + await expect(page).toHaveURL('http://127.0.0.1:4174/?demo=familiars-reads'); + await expect(page.getByRole('complementary', { name: 'Conversations sidebar' })).toBeVisible(); + await expect(page.getByRole('button', { name: /Q3 pricing evidence map/ })).toBeVisible(); + // Stage 1 has no send capability yet; the composer notice says so rather + // than offering a working-looking control. + await expect(page.getByText(/^Sending: Not available yet/)).toBeVisible(); }); test('keeps the chat sidebar compact and fully interactive', async ({ page }) => { diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 4d093438..1388debb 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -19,6 +19,8 @@ const NATIVE_COMMANDS: &[&str] = &[ "cave_list_conversations", "cave_get_conversation", "cave_list_conversation_messages", + "cave_familiar_contract", + "cave_familiar_analytics", ]; fn tauri_config_value<'a>(config: &'a serde_json::Value, key: &str) -> &'a str { diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index b8eb9a5d..d9bd9a8c 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -21,6 +21,8 @@ "allow-cave-list-projects", "allow-cave-list-conversations", "allow-cave-get-conversation", - "allow-cave-list-conversation-messages" + "allow-cave-list-conversation-messages", + "allow-cave-familiar-contract", + "allow-cave-familiar-analytics" ] } diff --git a/src-tauri/gen/schemas/desktop-schema.json b/src-tauri/gen/schemas/desktop-schema.json index 8850d16f..8c65b481 100644 --- a/src-tauri/gen/schemas/desktop-schema.json +++ b/src-tauri/gen/schemas/desktop-schema.json @@ -200,6 +200,18 @@ "const": "allow-cave-credential-status", "markdownDescription": "Enables the cave_credential_status command without any pre-configured scope." }, + { + "description": "Enables the cave_familiar_analytics command without any pre-configured scope.", + "type": "string", + "const": "allow-cave-familiar-analytics", + "markdownDescription": "Enables the cave_familiar_analytics command without any pre-configured scope." + }, + { + "description": "Enables the cave_familiar_contract command without any pre-configured scope.", + "type": "string", + "const": "allow-cave-familiar-contract", + "markdownDescription": "Enables the cave_familiar_contract command without any pre-configured scope." + }, { "description": "Enables the cave_forget_credential command without any pre-configured scope.", "type": "string", @@ -308,6 +320,18 @@ "const": "deny-cave-credential-status", "markdownDescription": "Denies the cave_credential_status command without any pre-configured scope." }, + { + "description": "Denies the cave_familiar_analytics command without any pre-configured scope.", + "type": "string", + "const": "deny-cave-familiar-analytics", + "markdownDescription": "Denies the cave_familiar_analytics command without any pre-configured scope." + }, + { + "description": "Denies the cave_familiar_contract command without any pre-configured scope.", + "type": "string", + "const": "deny-cave-familiar-contract", + "markdownDescription": "Denies the cave_familiar_contract command without any pre-configured scope." + }, { "description": "Denies the cave_forget_credential command without any pre-configured scope.", "type": "string", diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index cdf718bf..88031d39 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -28,6 +28,8 @@ pub const REGISTERED_COMMANDS: &[&str] = &[ "cave_list_conversations", "cave_get_conversation", "cave_list_conversation_messages", + "cave_familiar_contract", + "cave_familiar_analytics", ]; #[tauri::command] @@ -289,6 +291,51 @@ pub async fn cave_list_conversation_messages( .await } +#[tauri::command] +pub async fn cave_familiar_contract( + handle: String, + familiar_id: String, + operation: NativeOperationInput, + state: State<'_, NativeConnectionState>, +) -> Result { + let runner = state.inner().clone(); + let operation_state = runner.clone(); + runner + .run_operation(operation, async move { + operation_state + .cave_read(handle, CaveReadPath::FamiliarContract { familiar_id }) + .await + }) + .await +} + +#[tauri::command] +pub async fn cave_familiar_analytics( + handle: String, + familiar_id: String, + window: Option, + recent_limit: Option, + operation: NativeOperationInput, + state: State<'_, NativeConnectionState>, +) -> Result { + let runner = state.inner().clone(); + let operation_state = runner.clone(); + runner + .run_operation(operation, async move { + operation_state + .cave_read( + handle, + CaveReadPath::FamiliarAnalytics { + familiar_id, + window, + recent_limit, + }, + ) + .await + }) + .await +} + pub fn registered_command_names() -> &'static [&'static str] { REGISTERED_COMMANDS } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 62d08ca7..b5de744b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -29,10 +29,11 @@ use transport::{ConstrainedTransport, NativeCaveTransport}; pub use commands::{ app_identity, app_installation_id, cave_cancel_operation, cave_credential_status, - cave_forget_credential, cave_get_conversation, cave_health, cave_launch, - cave_list_conversation_messages, cave_list_conversations, cave_list_familiars, - cave_list_projects, cave_pairing_create, cave_pairing_exchange, cave_pairing_poll, - cave_read_discovery, cave_reset_pairing, coven_health, registered_command_names, + cave_familiar_analytics, cave_familiar_contract, cave_forget_credential, cave_get_conversation, + cave_health, cave_launch, cave_list_conversation_messages, cave_list_conversations, + cave_list_familiars, cave_list_projects, cave_pairing_create, cave_pairing_exchange, + cave_pairing_poll, cave_read_discovery, cave_reset_pairing, coven_health, + registered_command_names, }; pub use coven::CovenHealthResult; pub use metadata::{AppIdentity, APP_IDENTIFIER, APP_NAME, APP_PHASE}; @@ -267,7 +268,9 @@ fn builder() -> tauri::Builder { cave_list_projects, cave_list_conversations, cave_get_conversation, - cave_list_conversation_messages + cave_list_conversation_messages, + cave_familiar_contract, + cave_familiar_analytics ]) } @@ -462,6 +465,8 @@ mod smoke_tests { "cave_list_conversations", "cave_get_conversation", "cave_list_conversation_messages", + "cave_familiar_contract", + "cave_familiar_analytics", ] ); } diff --git a/src-tauri/src/transport.rs b/src-tauri/src/transport.rs index cd9d843e..4e0fcbcd 100644 --- a/src-tauri/src/transport.rs +++ b/src-tauri/src/transport.rs @@ -92,6 +92,14 @@ pub(crate) enum CaveReadPath { conversation_id: String, page: NativePage, }, + FamiliarContract { + familiar_id: String, + }, + FamiliarAnalytics { + familiar_id: String, + window: Option, + recent_limit: Option, + }, } #[derive(Clone, Deserialize)] @@ -175,6 +183,23 @@ impl CaveReadPath { validate_canonical_conversation_id(conversation_id)?; page.validate() } + Self::FamiliarContract { familiar_id } => validate_canonical_familiar_id(familiar_id), + Self::FamiliarAnalytics { + familiar_id, + window, + recent_limit, + } => { + validate_canonical_familiar_id(familiar_id)?; + if let Some(window) = window { + if !matches!(window.as_str(), "7d" | "14d" | "8w" | "all") { + return Err(NativeDiagnostic::new("invalid_native_input", false)); + } + } + if recent_limit.is_some_and(|limit| limit > 100) { + return Err(NativeDiagnostic::new("invalid_native_input", false)); + } + Ok(()) + } } } } @@ -215,6 +240,20 @@ fn validate_canonical_conversation_id(value: &str) -> NativeResult<()> { Ok(()) } +fn validate_canonical_familiar_id(value: &str) -> NativeResult<()> { + let bytes = value.as_bytes(); + if bytes.is_empty() + || bytes.len() > 64 + || !bytes[0].is_ascii_alphanumeric() + || !bytes + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + return Err(NativeDiagnostic::new("invalid_native_input", false)); + } + Ok(()) +} + impl ConstrainedTransport { fn client() -> NativeResult { Client::builder() @@ -414,6 +453,28 @@ impl NativeCaveTransport for ConstrainedTransport { }, Some(page), ), + CaveReadPath::FamiliarContract { familiar_id } => ( + format!( + "api/client/v1/familiars/{}/contract", + encoded_cave_path_segment(&familiar_id) + ), + None, + ), + CaveReadPath::FamiliarAnalytics { + familiar_id, + window, + recent_limit, + } => ( + with_analytics_query( + format!( + "api/client/v1/familiars/{}/analytics", + encoded_cave_path_segment(&familiar_id) + ), + window, + recent_limit, + ), + None, + ), }; let path = with_page(path, page)?; Self::request(authority, Method::GET, &path, Some(bearer), None, None).await @@ -500,6 +561,26 @@ fn with_page(mut path: String, page: Option) -> NativeResult Ok(path) } +fn with_analytics_query( + mut path: String, + window: Option, + recent_limit: Option, +) -> String { + if window.is_none() && recent_limit.is_none() { + return path; + } + let mut serializer = url::form_urlencoded::Serializer::new(String::new()); + if let Some(window) = window { + serializer.append_pair("window", &window); + } + if let Some(limit) = recent_limit { + serializer.append_pair("recent", &limit.to_string()); + } + path.push('?'); + path.push_str(&serializer.finish()); + path +} + async fn read_response(mut response: reqwest::Response) -> NativeResult { if response.status().is_redirection() { return Err(NativeDiagnostic::new("redirect_rejected", false)); @@ -793,6 +874,46 @@ mod tests { } } + #[test] + fn canonical_familiar_ids_are_bounded_to_one_unescaped_path_segment() { + assert_eq!(encoded_cave_path_segment("astra-01"), "astra-01"); + let too_long = "a".repeat(65); + for familiar_id in [ + "", ".", "..", "a/b", "space id", "雪", "percent%", &too_long, + ] { + assert!(CaveReadPath::FamiliarContract { + familiar_id: familiar_id.to_owned(), + } + .validate() + .is_err()); + } + } + + #[test] + fn familiar_analytics_rejects_out_of_bound_window_and_recent_limit() { + assert!(CaveReadPath::FamiliarAnalytics { + familiar_id: "astra".to_owned(), + window: Some("30d".to_owned()), + recent_limit: None, + } + .validate() + .is_err()); + assert!(CaveReadPath::FamiliarAnalytics { + familiar_id: "astra".to_owned(), + window: None, + recent_limit: Some(101), + } + .validate() + .is_err()); + assert!(CaveReadPath::FamiliarAnalytics { + familiar_id: "astra".to_owned(), + window: Some("7d".to_owned()), + recent_limit: Some(100), + } + .validate() + .is_ok()); + } + #[test] fn endpoint_takeover_receives_ciphertext_only_and_cannot_forge_a_response() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); diff --git a/src/app.test.tsx b/src/app.test.tsx index 855896cf..bb4ab30d 100644 --- a/src/app.test.tsx +++ b/src/app.test.tsx @@ -107,6 +107,8 @@ function makeQueryAdapter(): QueryAdapter { ], }, }), + familiarContract: vi.fn().mockResolvedValue({ status: 'not_ready' }), + familiarAnalytics: vi.fn().mockResolvedValue({ status: 'not_ready' }), invalidate: vi.fn(), dispose: vi.fn(), }; diff --git a/src/chat-shell.test.tsx b/src/chat-shell.test.tsx index ba37c4e9..9a17f2b4 100644 --- a/src/chat-shell.test.tsx +++ b/src/chat-shell.test.tsx @@ -126,6 +126,8 @@ function makeQueryAdapter(overrides: Partial = {}): QueryAdapter { }, ]), ), + familiarContract: vi.fn().mockResolvedValue({ status: 'not_ready' }), + familiarAnalytics: vi.fn().mockResolvedValue({ status: 'not_ready' }), invalidate: vi.fn(), dispose: vi.fn(), ...overrides, diff --git a/src/familiars/capabilities.test.ts b/src/familiars/capabilities.test.ts new file mode 100644 index 00000000..e3060ee9 --- /dev/null +++ b/src/familiars/capabilities.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest'; + +import { availabilityFor, CONTROL_NAMES } from './capabilities'; +import type { Capability } from './source'; + +const STAGE_1: ReadonlySet = new Set([ + 'familiars', + 'conversations', + 'conversation-messages', +]); +const STAGE_1_PLUS_CONTRACT: ReadonlySet = new Set([...STAGE_1, 'familiar-contract']); +const STAGE_1_PLUS_ANALYTICS: ReadonlySet = new Set([...STAGE_1, 'familiar-analytics']); +const EVERYTHING: ReadonlySet = new Set([ + 'familiars', + 'conversations', + 'conversation-messages', + 'familiar-contract', + 'familiar-analytics', + 'conversations-write', + 'runs', + 'conversation-participants', + 'attention', + 'rich-content', + 'attachments', + 'familiars-write', + 'screen', +]); + +describe('availabilityFor', () => { + it('covers every control the design gating table names', () => { + expect(CONTROL_NAMES).toEqual([ + 'sidebar', + 'thread', + 'overview', + 'access', + 'activity', + 'composer-send', + 'mentions', + 'held-actions', + 'reasoning-steps', + 'image-cards', + 'summon', + 'screen', + ]); + }); + + it('enables sidebar, thread, and overview once the shipped Stage 1 reads are present', () => { + for (const control of ['sidebar', 'thread', 'overview'] as const) { + expect(availabilityFor(control, STAGE_1)).toEqual({ enabled: true }); + expect(availabilityFor(control, new Set())).toEqual({ + enabled: false, + reason: expect.stringContaining('familiars'), + }); + } + }); + + it('gates the Access tab on familiar-contract alone', () => { + expect(availabilityFor('access', STAGE_1)).toEqual({ + enabled: false, + reason: 'Not available yet — this instance does not advertise familiar-contract.', + }); + expect(availabilityFor('access', STAGE_1_PLUS_CONTRACT)).toEqual({ enabled: true }); + }); + + it('gates the Activity tab on familiar-analytics alone', () => { + expect(availabilityFor('activity', STAGE_1)).toEqual({ + enabled: false, + reason: 'Not available yet — this instance does not advertise familiar-analytics.', + }); + expect(availabilityFor('activity', STAGE_1_PLUS_ANALYTICS)).toEqual({ enabled: true }); + }); + + it('gates composer send on both conversations-write and runs, naming every missing one', () => { + expect(availabilityFor('composer-send', STAGE_1)).toEqual({ + enabled: false, + reason: 'Not available yet — this instance does not advertise conversations-write, runs.', + }); + expect(availabilityFor('composer-send', new Set([...STAGE_1, 'conversations-write']))).toEqual({ + enabled: false, + reason: 'Not available yet — this instance does not advertise runs.', + }); + }); + + it('gates mentions, held actions, reasoning steps, image cards, summon, and screen on their own capability', () => { + expect(availabilityFor('mentions', STAGE_1)).toMatchObject({ enabled: false }); + expect(availabilityFor('mentions', new Set([...STAGE_1, 'conversation-participants']))).toEqual( + { + enabled: true, + }, + ); + expect(availabilityFor('held-actions', new Set([...STAGE_1, 'attention']))).toEqual({ + enabled: true, + }); + expect(availabilityFor('reasoning-steps', new Set([...STAGE_1, 'rich-content']))).toEqual({ + enabled: true, + }); + expect(availabilityFor('image-cards', new Set([...STAGE_1, 'attachments']))).toEqual({ + enabled: true, + }); + expect(availabilityFor('summon', new Set([...STAGE_1, 'familiars-write']))).toEqual({ + enabled: true, + }); + expect(availabilityFor('screen', new Set([...STAGE_1, 'screen']))).toEqual({ enabled: true }); + }); + + it('enables every control once every capability is advertised', () => { + for (const control of CONTROL_NAMES) { + expect(availabilityFor(control, EVERYTHING)).toEqual({ enabled: true }); + } + }); + + it('disables every control given no capabilities at all', () => { + for (const control of CONTROL_NAMES) { + expect(availabilityFor(control, new Set()).enabled).toBe(false); + } + }); +}); diff --git a/src/familiars/capabilities.ts b/src/familiars/capabilities.ts new file mode 100644 index 00000000..77feb7a8 --- /dev/null +++ b/src/familiars/capabilities.ts @@ -0,0 +1,66 @@ +import type { Capability } from './source'; + +/** + * Per-control capability gating, from the integration design's table + * (`docs/superpowers/specs/2026-09-02-familiars-integration-design.md` + * "Capability gating"). A control whose capability the source does not + * advertise renders disabled with a one-line reason -- never as a working + * mock. + */ +export type ControlName = + | 'sidebar' + | 'thread' + | 'overview' + | 'access' + | 'activity' + | 'composer-send' + | 'mentions' + | 'held-actions' + | 'reasoning-steps' + | 'image-cards' + | 'summon' + | 'screen'; + +export type ControlAvailability = Readonly<{ enabled: boolean; reason?: string }>; + +const CONTROL_REQUIREMENTS: Readonly> = { + sidebar: ['familiars', 'conversations', 'conversation-messages'], + thread: ['familiars', 'conversations', 'conversation-messages'], + overview: ['familiars', 'conversations', 'conversation-messages'], + access: ['familiar-contract'], + activity: ['familiar-analytics'], + 'composer-send': ['conversations-write', 'runs'], + mentions: ['conversation-participants'], + 'held-actions': ['attention'], + 'reasoning-steps': ['rich-content'], + 'image-cards': ['attachments'], + summon: ['familiars-write'], + screen: ['screen'], +}; + +/** The controls every `FamiliarsSource` must be gated on. Order is stable for tests. */ +export const CONTROL_NAMES: readonly ControlName[] = Object.keys( + CONTROL_REQUIREMENTS, +) as ControlName[]; + +function reasonFor(missing: readonly Capability[]): string { + return missing.length === 1 + ? `Not available yet — this instance does not advertise ${missing[0]}.` + : `Not available yet — this instance does not advertise ${missing.join(', ')}.`; +} + +/** Whether `control` is enabled given the capabilities a source advertises. */ +export function availabilityFor( + control: ControlName, + capabilities: ReadonlySet, +): ControlAvailability { + const missing = CONTROL_REQUIREMENTS[control].filter( + (capability) => !capabilities.has(capability), + ); + + if (missing.length === 0) { + return { enabled: true }; + } + + return { enabled: false, reason: reasonFor(missing) }; +} diff --git a/src/familiars/mock-source.test.ts b/src/familiars/mock-source.test.ts new file mode 100644 index 00000000..c33dfb2a --- /dev/null +++ b/src/familiars/mock-source.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest'; + +import { FAM_CONVERSATIONS, FAM_MESSAGES } from '../demo/familiars-data'; +import { MOCK_FAMILIARS } from '../demo/mock-familiars'; +import { createMockFamiliarsSource } from './mock-source'; +import type { QueryResult } from './source'; + +function unwrap(result: QueryResult): T { + if (result.status !== 'ok') { + throw new Error(`Expected an ok result, got ${result.status}.`); + } + return result.data; +} + +describe('createMockFamiliarsSource', () => { + it('lists every mock familiar as a summary', async () => { + const source = createMockFamiliarsSource(); + const page = unwrap(await source.familiars()); + + expect(page.data.map((familiar) => familiar.id)).toEqual( + MOCK_FAMILIARS.map((familiar) => familiar.id), + ); + expect(page.data[0]).toEqual({ + id: 'astra', + name: 'Astra', + role: 'Research and synthesis', + description: MOCK_FAMILIARS[0]?.description, + pronouns: 'she/her', + status: 'available', + }); + }); + + it('resolves a known familiar detail with identity, ward, and a passing report', async () => { + const source = createMockFamiliarsSource(); + const detail = unwrap(await source.familiar('astra')); + + expect(detail.id).toBe('astra'); + expect(detail.identity).toEqual({ + name: 'Astra', + creature: 'Cartographer', + person: 'Val Alexander', + }); + expect(detail.ward?.approvalTiers.humanReview).toContain('publish a finding'); + expect(detail.report.pass).toBe(true); + expect(detail.present).toEqual({ soul: true, identity: true, ward: true, memory: true }); + }); + + it('reports memory absence as a failing contract property with a matching violation', async () => { + const source = createMockFamiliarsSource(); + const detail = unwrap(await source.familiar('echo')); + + expect(detail.present.memory).toBe(false); + expect(detail.report.pass).toBe(false); + expect( + detail.report.violations.some((violation) => violation.field === 'Persistent Memory'), + ).toBe(true); + }); + + it('returns not_found for an unknown familiar id', async () => { + const source = createMockFamiliarsSource(); + expect(await source.familiar('nonexistent')).toEqual({ status: 'error', code: 'not_found' }); + }); + + it('derives a numeric activity view from the presentation-shaped mock data', async () => { + const source = createMockFamiliarsSource({ now: () => new Date('2026-08-25T00:00:00.000Z') }); + const activity = unwrap(await source.activity('astra')); + + expect(activity.window).toBe('7d'); + expect(activity.completion).toBe(1); + expect(activity.completed).toBe(13); // 12 completed + 1 held-for-you + expect(activity.failed).toBe(0); + expect(activity.calls).toBe(148); + expect(activity.medianDurationMs).toBe(96_000); // "1m 36s" + expect(activity.days).toHaveLength(7); + expect(activity.days?.[6]?.date).toBe('2026-08-25'); + expect(activity.recent.length).toBeGreaterThan(0); + expect(activity.recent[0]?.toolCalls).toBe(14); + }); + + it('returns not_found activity for a familiar id with no activity entry', async () => { + const source = createMockFamiliarsSource(); + expect(await source.activity('nonexistent')).toEqual({ status: 'error', code: 'not_found' }); + }); + + it('lists conversations with held mapped to pending and failed carried through', async () => { + const source = createMockFamiliarsSource(); + const page = unwrap(await source.conversations()); + + expect(page.data).toHaveLength(FAM_CONVERSATIONS.length); + const pricing = page.data.find((conversation) => conversation.id === 'pricing'); + expect(pricing?.pending).toBe(true); + expect(pricing?.failed).toBe(false); + const flaky = page.data.find((conversation) => conversation.id === 'flaky'); + expect(flaky?.failed).toBe(true); + expect(flaky?.pending).toBe(false); + }); + + it('drops reasoning, hold, image, and divider messages, keeping only user and familiar text', async () => { + const source = createMockFamiliarsSource(); + const page = unwrap(await source.messages('pricing')); + const sourceKinds = new Set(FAM_MESSAGES.pricing?.map((message) => message.kind)); + + expect(sourceKinds.has('reasoning')).toBe(true); // the fixture actually exercises the drop + expect( + page.data.every((message) => message.role === 'user' || message.role === 'assistant'), + ).toBe(true); + expect(page.data.length).toBeLessThan(FAM_MESSAGES.pricing?.length ?? 0); + }); + + it('chains parentId across the filtered message list, not the original index', async () => { + const source = createMockFamiliarsSource(); + const page = unwrap(await source.messages('pricing')); + + expect(page.data[0]?.parentId).toBeNull(); + for (let index = 1; index < page.data.length; index += 1) { + expect(page.data[index]?.parentId).toBe(page.data[index - 1]?.id); + } + }); + + it('returns not_found for an unknown conversation id', async () => { + const source = createMockFamiliarsSource(); + expect(await source.messages('nonexistent')).toEqual({ status: 'error', code: 'not_found' }); + }); + + it('advertises the Stage 1 capability set by default and honors an override', async () => { + expect([...createMockFamiliarsSource().capabilities()].sort()).toEqual([ + 'conversation-messages', + 'conversations', + 'familiar-analytics', + 'familiar-contract', + 'familiars', + ]); + const empty = createMockFamiliarsSource({ capabilities: new Set() }); + expect(empty.capabilities().size).toBe(0); + }); +}); diff --git a/src/familiars/mock-source.ts b/src/familiars/mock-source.ts new file mode 100644 index 00000000..08620dec --- /dev/null +++ b/src/familiars/mock-source.ts @@ -0,0 +1,354 @@ +import type { Page, PageCursor } from '@opencoven/sdk-core/browser'; + +import { + FAM_ACTIVITY, + FAM_CONVERSATIONS, + FAM_MESSAGES, + type FamActivity, + type FamConversation, + type FamMessage, + type FamRun, + runRow, +} from '../demo/familiars-data'; +import { contractReport, MOCK_FAMILIARS, type MockFamiliar } from '../demo/mock-familiars'; +import type { + ActivityAttempt, + ActivityDay, + ActivityWindow, + Capability, + ConversationSummary, + FamiliarActivity, + FamiliarDetail, + FamiliarPresence, + FamiliarSummary, + FamiliarsSource, + QueryResult, + ThreadMessage, +} from './source'; + +/** + * `FamiliarsSource` over today's demo fixtures (`src/demo/familiars-data.ts`, + * `src/demo/mock-familiars.ts`). + * + * This is the only place mock content lives, per the integration design. + * Used by the demo build, tests, and the design board states. It always + * resolves synchronously, and returns `status: 'ok'` for every id the demo + * fixtures cover; an id they do not cover resolves + * `{ status: 'error', code: 'not_found' }` rather than throwing. + * + * The demo's `FamActivity` is presentation-shaped (`completion: '100%'`, + * `median: '1m 36s'`) because it was authored to match the design mockup + * verbatim. Reconstructing the numeric `FamiliarActivity` view type from it + * is necessarily best-effort -- fields with no honest numeric source + * (per-attempt harness ids, real calendar dates) are synthesized rather than + * invented as if real. `CaveFamiliarsSource` (`./cave-source.ts`) gets these + * fields directly from Cave; only the mock path parses strings. + */ + +const STAGE_1_CAPABILITIES: ReadonlySet = new Set([ + 'familiars', + 'conversations', + 'conversation-messages', + 'familiar-contract', + 'familiar-analytics', +]); + +export type MockFamiliarsSourceOptions = Readonly<{ + now?: () => Date; + capabilities?: ReadonlySet; +}>; + +function pageOf(data: readonly T[]): Page { + const cursor: PageCursor = { hasMore: false }; + return { data, cursor }; +} + +function ok(data: T): QueryResult { + return { status: 'ok', data }; +} + +function mockFamiliarSummary(familiar: MockFamiliar): FamiliarSummary { + return { + id: familiar.id, + name: familiar.name, + role: familiar.role, + description: familiar.description, + pronouns: familiar.pronouns, + status: familiar.status, + }; +} + +function mockFamiliarPresence(familiar: MockFamiliar): FamiliarPresence { + return { + soul: familiar.soul.purpose.trim().length > 0, + identity: familiar.name.trim().length > 0 && familiar.creature.trim().length > 0, + ward: true, + memory: familiar.memory !== null, + }; +} + +function mockFamiliarDetail(familiar: MockFamiliar): FamiliarDetail { + const checks = contractReport(familiar); + + return { + id: familiar.id, + present: mockFamiliarPresence(familiar), + identity: { name: familiar.name, creature: familiar.creature, person: familiar.person }, + ward: { + version: familiar.ward.version, + protectedFiles: familiar.ward.protectedFiles, + invariants: familiar.ward.invariants, + editablePaths: familiar.ward.editablePaths, + approvalTiers: { + auto: familiar.ward.approvalTiers.auto, + humanReview: familiar.ward.approvalTiers.humanReview, + }, + }, + report: { + specVersion: '0.1.0', + pass: checks.every((check) => check.pass), + properties: checks.map((check) => ({ property: check.property, pass: check.pass })), + violations: checks + .filter((check) => !check.pass) + .map((check) => ({ file: check.file, field: check.property, message: check.note })), + warnings: [], + }, + }; +} + +function parsePercent(value: string): number | null { + const match = /^(\d+(?:\.\d+)?)%$/.exec(value.trim()); + return match?.[1] === undefined ? null : Number(match[1]) / 100; +} + +function parseIntLoose(value: string): number { + const match = /\d+/.exec(value); + return match === null ? 0 : Number(match[0]); +} + +/** Parses a "1h 2m 3s" / "4m 12s" / "58s" duration into milliseconds. */ +function parseDurationMs(value: string | undefined): number | undefined { + if (value === undefined) { + return undefined; + } + const match = /^(?:(\d+)h)?\s*(?:(\d+)m)?\s*(?:(\d+)s)?$/.exec(value.trim()); + if ( + match === null || + (match[1] === undefined && match[2] === undefined && match[3] === undefined) + ) { + return undefined; + } + const hours = Number(match[1] ?? 0); + const minutes = Number(match[2] ?? 0); + const seconds = Number(match[3] ?? 0); + return (hours * 3600 + minutes * 60 + seconds) * 1000; +} + +function findSpread(activity: FamActivity, label: string): string | undefined { + return activity.spread.find(([entryLabel]) => entryLabel === label)?.[1]; +} + +function outcomeCount(activity: FamActivity, label: string): number { + return activity.outcomes.find(([entryLabel]) => entryLabel === label)?.[1] ?? 0; +} + +function slugify(text: string): string { + return text + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-+|-+$)/g, ''); +} + +function mockDayDate(now: Date, index: number, total: number): string { + const offset = total - 1 - index; + const date = new Date(now.getTime()); + date.setUTCDate(date.getUTCDate() - offset); + return date.toISOString().slice(0, 10); +} + +function mockActivityDay( + [completed, failed]: readonly [number, number], + index: number, + now: Date, + total: number, +): ActivityDay { + return { date: mockDayDate(now, index, total), completed, failed, cancelled: 0 }; +} + +function mockActivityAttempt(run: FamRun, index: number, now: Date): ActivityAttempt { + const row = runRow(run); + const durationMs = parseDurationMs(row.dur); + + return { + id: `${slugify(run.title)}-${index}`, + occurredAt: new Date(now.getTime() - index * 60 * 60 * 1000).toISOString(), + harnessId: 'demo-harness', + status: run.status === 'failed' ? 'failed' : 'completed', + ...(durationMs === undefined ? {} : { durationMs }), + toolCalls: parseIntLoose(row.calls), + toolFailures: run.status === 'failed' ? 1 : 0, + }; +} + +function mockFamiliarActivity( + activity: FamActivity, + window: ActivityWindow, + now: Date, +): FamiliarActivity { + const completed = outcomeCount(activity, 'completed') + outcomeCount(activity, 'held for you'); + const failed = outcomeCount(activity, 'failed'); + const medianDurationMs = parseDurationMs(findSpread(activity, 'p50')); + const p95DurationMs = parseDurationMs(findSpread(activity, 'p90')); + + return { + window, + generatedAt: now.toISOString(), + attempts: completed + failed, + completed, + failed, + cancelled: 0, + completion: parsePercent(activity.completion), + ...(medianDurationMs === undefined ? {} : { medianDurationMs }), + ...(p95DurationMs === undefined ? {} : { p95DurationMs }), + calls: parseIntLoose(activity.calls), + callFailures: activity.tools.reduce((sum, tool) => sum + (tool.failed ?? 0), 0), + tools: activity.tools.map((tool) => ({ + name: tool.name, + calls: tool.calls, + failed: tool.failed ?? 0, + })), + days: activity.days.map((day, index) => mockActivityDay(day, index, now, activity.days.length)), + recent: activity.recent.map((run, index) => mockActivityAttempt(run, index, now)), + backfillState: 'complete', + }; +} + +function mockConversationSummary( + conversation: FamConversation, + index: number, + now: Date, +): ConversationSummary { + return { + id: conversation.id, + familiarId: conversation.familiarId, + title: conversation.title, + // The demo carries a display string ("10:52 PM", "Yesterday"), not a + // timestamp; index-ordered synthesis keeps the list's own order stable. + updatedAt: new Date(now.getTime() - index * 60_000).toISOString(), + failed: conversation.failed === true, + pending: conversation.held === true, + }; +} + +/** + * One `FamMessage` -> zero or one `ThreadMessage`. + * + * `divider`, `reasoning`, `image`, `hold`, and `failed` messages have no + * Stage 1 equivalent (Cave does not serve the rich-content AST, attachments, + * or attention items yet) and are dropped rather than faked. + */ +function mockThreadMessage( + conversationId: string, + message: FamMessage, + id: string, + parentId: string | null, + createdAt: string, +): ThreadMessage | undefined { + if (message.kind === 'user') { + return { + id, + conversationId, + parentId, + role: 'user', + text: message.text, + createdAt, + attachmentCount: message.attachments?.length ?? 0, + toolCount: 0, + isError: false, + cancelled: false, + }; + } + if (message.kind === 'familiar') { + return { + id, + conversationId, + parentId, + role: 'assistant', + text: message.text, + createdAt, + attachmentCount: 0, + toolCount: 0, + isError: false, + cancelled: false, + }; + } + return undefined; +} + +function mockThreadMessages( + conversationId: string, + messages: readonly FamMessage[], + now: Date, +): ThreadMessage[] { + const result: ThreadMessage[] = []; + let parentId: string | null = null; + + messages.forEach((message, index) => { + const id = `${conversationId}-${index}`; + const createdAt = new Date(now.getTime() - (messages.length - index) * 60_000).toISOString(); + const mapped = mockThreadMessage(conversationId, message, id, parentId, createdAt); + if (mapped !== undefined) { + result.push(mapped); + parentId = mapped.id; + } + }); + + return result; +} + +export function createMockFamiliarsSource( + options: MockFamiliarsSourceOptions = {}, +): FamiliarsSource { + const now = options.now ?? (() => new Date()); + const capabilities = options.capabilities ?? STAGE_1_CAPABILITIES; + + return Object.freeze({ + async familiars(): Promise>> { + return ok(pageOf(MOCK_FAMILIARS.map(mockFamiliarSummary))); + }, + async familiar(id: string): Promise> { + const familiar = MOCK_FAMILIARS.find((candidate) => candidate.id === id); + return familiar === undefined + ? { status: 'error', code: 'not_found' } + : ok(mockFamiliarDetail(familiar)); + }, + async activity( + id: string, + window: ActivityWindow = '7d', + ): Promise> { + const activity = FAM_ACTIVITY[id]; + return activity === undefined + ? { status: 'error', code: 'not_found' } + : ok(mockFamiliarActivity(activity, window, now())); + }, + async conversations(): Promise>> { + const current = now(); + return ok( + pageOf( + FAM_CONVERSATIONS.map((conversation, index) => + mockConversationSummary(conversation, index, current), + ), + ), + ); + }, + async messages(conversationId: string): Promise>> { + const messages = FAM_MESSAGES[conversationId]; + return messages === undefined + ? { status: 'error', code: 'not_found' } + : ok(pageOf(mockThreadMessages(conversationId, messages, now()))); + }, + capabilities(): ReadonlySet { + return capabilities; + }, + }); +} diff --git a/src/familiars/reads-shell.css b/src/familiars/reads-shell.css new file mode 100644 index 00000000..cbd2bdd5 --- /dev/null +++ b/src/familiars/reads-shell.css @@ -0,0 +1,213 @@ +/* + * Minimal styling for `FamiliarsReadsShell` (`.frs-*`). + * + * This is a functional Stage 1 preview, not a design pass: three columns, + * readable defaults, and the app's own tokens where they already exist. + * It intentionally does not borrow `.fr-*` from the demo shell -- the two + * surfaces render different things and must not couple their CSS. + */ + +.frs-shell { + display: grid; + grid-template-columns: 260px minmax(0, 1fr) 300px; + height: 100%; + min-height: 0; + color: var(--text-primary, #e6e6e6); + background: var(--bg-base, #0b0b0d); + font-family: var(--font-inter, -apple-system, sans-serif); +} + +.frs-sidebar, +.frs-inspector { + display: flex; + flex-direction: column; + gap: 16px; + padding: 16px; + overflow-y: auto; + border-inline: 1px solid var(--border-subtle, #232326); +} + +.frs-heading { + margin: 0 0 8px; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; + color: var(--text-secondary, #9a9aa2); +} + +.frs-familiars-list, +.frs-conv-list, +.frs-messages { + display: flex; + flex-direction: column; + gap: 4px; + margin: 0; + padding: 0; + list-style: none; +} + +.frs-familiar-row { + display: flex; + justify-content: space-between; + padding: 6px 8px; + border-radius: var(--radius-control, 8px); +} + +.frs-conv { + display: flex; + flex-direction: column; + gap: 2px; + width: 100%; + padding: 8px; + text-align: left; + color: inherit; + background: none; + border: none; + border-radius: var(--radius-control, 8px); + cursor: pointer; +} + +.frs-conv:hover { + background: var(--bg-raised, #17171a); +} + +.frs-conv[aria-current="true"] { + background: var(--bg-raised, #17171a); + outline: 1px solid var(--accent-presence, #a79bf5); +} + +.frs-conv-title { + font-size: 13px; + font-weight: 500; +} + +.frs-conv-flag { + font-size: 11px; + color: var(--danger-text, #f28b82); +} + +.frs-conv-time { + font-size: 11px; + color: var(--text-secondary, #9a9aa2); +} + +.frs-thread { + display: flex; + flex-direction: column; + min-width: 0; +} + +.frs-thread-header { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 16px; + border-bottom: 1px solid var(--border-subtle, #232326); +} + +.frs-thread-title { + font-weight: 600; +} + +.frs-thread-familiar { + color: var(--text-secondary, #9a9aa2); +} + +.frs-transcript { + flex: 1; + min-height: 0; + padding: 16px; + overflow-y: auto; +} + +.frs-message { + max-width: 65%; + margin-bottom: 10px; + padding: 8px 12px; + border-radius: var(--radius-control, 8px); + background: var(--bg-raised, #17171a); +} + +.frs-message--user { + margin-inline-start: auto; + background: var(--accent-presence, #a79bf5); + color: #16131f; +} + +.frs-message-role { + display: block; + font-size: 10px; + text-transform: uppercase; + opacity: 0.7; +} + +.frs-message-text { + margin: 2px 0 0; + white-space: pre-wrap; +} + +.frs-status { + font-size: 13px; + color: var(--text-secondary, #9a9aa2); +} + +.frs-status--error { + color: var(--danger-text, #f28b82); +} + +.frs-muted { + color: var(--text-secondary, #9a9aa2); +} + +.frs-notices { + display: flex; + flex-direction: column; + gap: 4px; + padding: 8px 16px 16px; +} + +.frs-capability-notice { + margin: 0; + padding: 6px 10px; + font-size: 12px; + color: var(--text-secondary, #9a9aa2); + background: var(--bg-raised, #17171a); + border-radius: var(--radius-control, 8px); +} + +.frs-tabs { + display: flex; + gap: 4px; + padding: 4px; + background: var(--bg-raised, #17171a); + border-radius: var(--radius-control, 8px); +} + +.frs-tab { + flex: 1; + padding: 6px 8px; + font-size: 12px; + text-transform: capitalize; + color: var(--text-secondary, #9a9aa2); + background: none; + border: none; + border-radius: calc(var(--radius-control, 8px) - 2px); + cursor: pointer; +} + +.frs-tab[aria-selected="true"] { + color: var(--text-primary, #e6e6e6); + background: var(--bg-base, #0b0b0d); +} + +.frs-inspector-body { + display: flex; + flex-direction: column; + gap: 8px; +} + +.frs-inspector-body ul { + margin: 4px 0 0; + padding-left: 18px; +} diff --git a/src/familiars/reads-shell.test.tsx b/src/familiars/reads-shell.test.tsx new file mode 100644 index 00000000..3ec7d7f9 --- /dev/null +++ b/src/familiars/reads-shell.test.tsx @@ -0,0 +1,118 @@ +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { MOCK_FAMILIARS } from '../demo/mock-familiars'; +import { createMockFamiliarsSource } from './mock-source'; +import { FamiliarsReadsShell } from './reads-shell'; +import type { Capability, FamiliarsSource, QueryResult } from './source'; + +describe('FamiliarsReadsShell', () => { + it('lists familiars and conversations, then selects the first conversation', async () => { + render(); + + const sidebar = screen.getByRole('complementary', { name: 'Conversations sidebar' }); + await waitFor(() => { + expect(within(sidebar).getByText('Astra')).toBeInTheDocument(); + }); + for (const familiar of MOCK_FAMILIARS) { + expect(within(sidebar).getByText(familiar.name)).toBeInTheDocument(); + } + expect(screen.getByRole('button', { name: /Q3 pricing evidence map/ })).toHaveAttribute( + 'aria-current', + 'true', + ); + }); + + it('loads messages for the selected conversation, keeping only user and familiar text', async () => { + render(); + + await waitFor(() => { + expect( + screen.getByText( + 'Map the evidence for the Q3 pricing decision. Start from the two vendor decks in notes/pricing/.', + ), + ).toBeInTheDocument(); + }); + }); + + it('switches conversations on click and loads that thread', async () => { + render(); + + const flakyButton = await screen.findByRole('button', { name: /Flaky test in auth suite/ }); + fireEvent.click(flakyButton); + + await waitFor(() => { + expect(flakyButton).toHaveAttribute('aria-current', 'true'); + }); + }); + + it('shows the ward on the Access tab and analytics on the Activity tab', async () => { + render(); + await screen.findAllByText('Astra'); + + fireEvent.click(screen.getByRole('tab', { name: 'access' })); + await waitFor(() => { + expect(screen.getByText('publish a finding')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole('tab', { name: 'activity' })); + await waitFor(() => { + expect(screen.getByText(/runs completed/)).toBeInTheDocument(); + }); + }); + + it('renders a not-available notice for every Stage 2-4 control the mock source does not advertise', async () => { + render(); + await screen.findAllByText('Astra'); + + for (const label of ['Sending', '@-mentions', 'Held actions', 'Reasoning steps', 'Images']) { + expect(screen.getByText(new RegExp(`^${label}: Not available yet`))).toBeInTheDocument(); + } + for (const label of ['Summoning', 'Screen view']) { + expect(screen.getByText(new RegExp(`^${label}: Not available yet`))).toBeInTheDocument(); + } + }); + + it('renders the Access and Activity tabs disabled when the source does not advertise those capabilities', async () => { + const source = createMockFamiliarsSource({ capabilities: new Set() }); + render(); + await screen.findAllByText('Astra'); + + fireEvent.click(screen.getByRole('tab', { name: 'access' })); + expect(screen.getByText(/^Access: Not available yet/)).toBeInTheDocument(); + expect(screen.queryByText('publish a finding')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('tab', { name: 'activity' })); + expect(screen.getByText(/^Activity: Not available yet/)).toBeInTheDocument(); + expect(screen.queryByText(/runs completed/)).not.toBeInTheDocument(); + }); + + it('renders a diagnostic error state rather than crashing when a read fails', async () => { + const failingSource: FamiliarsSource = { + async familiars(): Promise> { + return { status: 'error', code: 'service_unavailable' }; + }, + async familiar() { + return { status: 'not_ready' }; + }, + async activity() { + return { status: 'not_ready' }; + }, + async conversations(): Promise> { + return { status: 'error', code: 'service_unavailable' }; + }, + async messages() { + return { status: 'not_ready' }; + }, + capabilities(): ReadonlySet { + return new Set(); + }, + }; + + render(); + + await waitFor(() => { + expect(screen.getAllByText(/Couldn’t load .* \(service_unavailable\)\./)).toHaveLength(2); + }); + }); +}); diff --git a/src/familiars/reads-shell.tsx b/src/familiars/reads-shell.tsx new file mode 100644 index 00000000..403c44c1 --- /dev/null +++ b/src/familiars/reads-shell.tsx @@ -0,0 +1,415 @@ +import { useEffect, useMemo, useState } from 'react'; + +import { availabilityFor, type ControlName } from './capabilities'; +import type { + Capability, + ConversationSummary, + FamiliarActivity, + FamiliarDetail, + FamiliarSummary, + FamiliarsSource, + Page, + QueryResult, + ThreadMessage, +} from './source'; + +/** + * The Familiars surface's Stage 1 shell: sidebar, thread, and inspector + * driven entirely by a `FamiliarsSource`, rendering only what Stage 1 + * (`docs/superpowers/plans/2026-09-02-familiars-integration.md`) can + * honestly serve. + * + * This is deliberately a *different*, smaller component from + * `src/demo/familiars-shell.tsx`. That shell previews the full design -- + * reasoning cards, held actions, image cards, `@`-mentions, summoning, the + * screen view -- none of which Cave serves yet. Rebuilding those against + * `FamiliarsSource` today would mean either inventing data Cave does not + * send or silently downgrading the shipped demo; neither is honest. Every + * control this shell cannot back with a real read renders a one-line + * "not available yet" notice instead, per `./capabilities.ts`, and nothing + * here is a working mock of a control it does not have data for. + */ + +export type FamiliarsReadsShellProps = Readonly<{ + source: FamiliarsSource; + initialConversationId?: string; +}>; + +type InspectorTab = 'overview' | 'access' | 'activity'; + +const INSPECTOR_TABS: readonly InspectorTab[] = ['overview', 'access', 'activity']; +const NOT_READY: QueryResult = { status: 'not_ready' }; + +function statusMessage(status: QueryResult['status']): string | null { + switch (status) { + case 'not_ready': + case 'loading': + return 'Loading…'; + case 'stale': + return 'Updating…'; + case 'reconcile_required': + return 'Reconnect to Cave to continue.'; + default: + return null; + } +} + +function ResultStatus({ result, label }: { result: QueryResult; label: string }) { + if (result.status === 'ok') { + return null; + } + if (result.status === 'error') { + return ( +

+ Couldn’t load {label} ({result.code}). +

+ ); + } + const message = statusMessage(result.status); + return message === null ? null : ( +

+ {message} +

+ ); +} + +function CapabilityNotice({ + control, + capabilities, + label, +}: { + control: ControlName; + capabilities: ReadonlySet; + label: string; +}) { + const availability = availabilityFor(control, capabilities); + return availability.enabled ? null : ( + + {label}: {availability.reason} + + ); +} + +function formatRelativeTime(iso: string, now: Date): string { + const then = new Date(iso).getTime(); + if (Number.isNaN(then)) { + return iso; + } + const minutes = Math.round((now.getTime() - then) / 60_000); + if (minutes < 1) { + return 'Just now'; + } + if (minutes < 60) { + return `${minutes}m ago`; + } + const hours = Math.round(minutes / 60); + if (hours < 24) { + return `${hours}h ago`; + } + return `${Math.round(hours / 24)}d ago`; +} + +function formatDuration(ms: number): string { + const totalSeconds = Math.round(ms / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`; +} + +export function FamiliarsReadsShell({ source, initialConversationId }: FamiliarsReadsShellProps) { + const capabilities = useMemo(() => source.capabilities(), [source]); + const [familiarsResult, setFamiliarsResult] = + useState>>(NOT_READY); + const [conversationsResult, setConversationsResult] = + useState>>(NOT_READY); + const [conversationId, setConversationId] = useState( + initialConversationId ?? null, + ); + const [messagesResult, setMessagesResult] = useState>>(NOT_READY); + const [tab, setTab] = useState('overview'); + const [detailResult, setDetailResult] = useState>(NOT_READY); + const [activityResult, setActivityResult] = useState>(NOT_READY); + + useEffect(() => { + let cancelled = false; + setFamiliarsResult({ status: 'loading' }); + setConversationsResult({ status: 'loading' }); + + void source.familiars().then((result) => { + if (!cancelled) { + setFamiliarsResult(result); + } + }); + void source.conversations().then((result) => { + if (cancelled) { + return; + } + setConversationsResult(result); + if (result.status === 'ok') { + setConversationId((current) => current ?? result.data.data[0]?.id ?? null); + } + }); + + return () => { + cancelled = true; + }; + }, [source]); + + useEffect(() => { + if (conversationId === null) { + return; + } + let cancelled = false; + setMessagesResult({ status: 'loading' }); + void source.messages(conversationId).then((result) => { + if (!cancelled) { + setMessagesResult(result); + } + }); + + return () => { + cancelled = true; + }; + }, [source, conversationId]); + + const activeConversation = + conversationsResult.status === 'ok' + ? conversationsResult.data.data.find((item) => item.id === conversationId) + : undefined; + const activeFamiliarId = activeConversation?.familiarId ?? null; + + useEffect(() => { + if (activeFamiliarId === null) { + return; + } + let cancelled = false; + + if (tab === 'access' && availabilityFor('access', capabilities).enabled) { + setDetailResult({ status: 'loading' }); + void source.familiar(activeFamiliarId).then((result) => { + if (!cancelled) { + setDetailResult(result); + } + }); + } + if (tab === 'activity' && availabilityFor('activity', capabilities).enabled) { + setActivityResult({ status: 'loading' }); + void source.activity(activeFamiliarId).then((result) => { + if (!cancelled) { + setActivityResult(result); + } + }); + } + + return () => { + cancelled = true; + }; + }, [source, activeFamiliarId, tab, capabilities]); + + const familiarsById = + familiarsResult.status === 'ok' + ? new Map(familiarsResult.data.data.map((item) => [item.id, item])) + : new Map(); + const activeFamiliar = + activeFamiliarId === null ? undefined : familiarsById.get(activeFamiliarId); + const now = new Date(); + const accessAvailability = availabilityFor('access', capabilities); + const activityAvailability = availabilityFor('activity', capabilities); + + return ( +
+ + +
+
+ + {activeConversation?.title ?? 'Select a conversation'} + + {activeFamiliar ? ( + {activeFamiliar.name} + ) : null} +
+
+ + {messagesResult.status === 'ok' ? ( +
    + {messagesResult.data.data.map((message) => ( +
  • + {message.role} +

    {message.text}

    +
  • + ))} + {messagesResult.data.data.length === 0 ? ( +
  • No messages yet.
  • + ) : null} +
+ ) : null} +
+
+ + + + + +
+
+ + +
+ ); +} diff --git a/src/familiars/source.ts b/src/familiars/source.ts new file mode 100644 index 00000000..f230d897 --- /dev/null +++ b/src/familiars/source.ts @@ -0,0 +1,188 @@ +import type { Page, PageCursor } from '@opencoven/sdk-core/browser'; + +import type { QueryResult } from '../lib/sdk/query-adapter'; + +export type { Page, PageCursor, QueryResult }; + +/** + * The Familiars surface's data-source seam. + * + * `FamiliarsShell` reads through this interface rather than module + * constants. One implementation exists today: `MockFamiliarsSource` + * (`./mock-source.ts`), wrapping `src/demo/familiars-data.ts` for the demo + * build and tests. The Cave-backed implementation, and the single mapping + * from SDK wire types to these view types, land with the SDK bump that + * exports `CaveFamiliarIdentity`, `CaveFamiliarWard`, and + * `CaveExecutionDay`; the seam is shaped for it so that arrival is additive. + * + * Scoped to Stage 1 (reads only) of + * `docs/superpowers/plans/2026-09-02-familiars-integration.md`. Send, + * attention, and mutation members are added by later stages, once Cave + * serves them. + */ + +/** Mirrors `CaveCanonicalFamiliar.status`, defaulted when Cave omits it. */ +export type FamiliarStatus = 'available' | 'working' | 'offline'; + +export type FamiliarSummary = Readonly<{ + id: string; + name: string; + role: string; + description?: string; + pronouns?: string; + status: FamiliarStatus; +}>; + +/** Which of the four contract files Cave found present. */ +export type FamiliarPresence = Readonly<{ + soul: boolean; + identity: boolean; + ward: boolean; + memory: boolean; +}>; + +/** IDENTITY.md-derived fields. Absent when Cave withholds or lacks them. */ +export type FamiliarIdentity = Readonly<{ + name?: string; + creature?: string; + person?: string; +}>; + +/** The ward parsed from `ward.toml`. Absent when Cave withholds or lacks it. */ +export type FamiliarWard = Readonly<{ + version?: string; + protectedFiles: readonly string[]; + invariants: readonly string[]; + editablePaths: readonly string[]; + approvalTiers: Readonly<{ + auto: readonly string[]; + humanReview: readonly string[]; + }>; +}>; + +export type ContractPropertyCoverage = Readonly<{ property: string; pass: boolean }>; +export type ContractViolation = Readonly<{ file: string; field: string; message: string }>; + +/** `pass` is true when there are zero hard violations; warnings never fail it. */ +export type ContractReport = Readonly<{ + specVersion: string; + pass: boolean; + properties: readonly ContractPropertyCoverage[]; + violations: readonly ContractViolation[]; + warnings: readonly ContractViolation[]; +}>; + +export type FamiliarDetail = Readonly<{ + id: string; + workspace?: string; + present: FamiliarPresence; + identity?: FamiliarIdentity; + ward?: FamiliarWard; + report: ContractReport; +}>; + +/** The windows Cave aggregates execution analytics over. */ +export type ActivityWindow = '7d' | '14d' | '8w' | 'all'; + +export type ExecutionOutcome = 'completed' | 'failed' | 'cancelled'; + +export type ActivityAttempt = Readonly<{ + id: string; + occurredAt: string; + harnessId: string; + status: ExecutionOutcome; + durationMs?: number; + toolCalls: number; + toolFailures: number; +}>; + +/** One UTC calendar day of a window's runs-per-day series. */ +export type ActivityDay = Readonly<{ + date: string; + completed: number; + failed: number; + cancelled: number; +}>; + +/** + * A tool- or harness-level usage slice. + * + * Cave's Stage 1 analytics contract does not break usage down by literal + * tool name; `mappers.ts` maps this from the window's `harnesses` slices, + * which is the closest Cave concept to the design's per-tool call counts. + */ +export type ActivityToolUsage = Readonly<{ name: string; calls: number; failed: number }>; + +/** Whether the history behind these numbers is complete. */ +export type ActivityBackfillState = 'complete' | 'partial' | 'not-started'; + +export type FamiliarActivity = Readonly<{ + window: ActivityWindow; + generatedAt: string; + attempts: number; + completed: number; + failed: number; + cancelled: number; + /** Null when there were no attempts: a rate over nothing is not zero. */ + completion: number | null; + medianDurationMs?: number; + p95DurationMs?: number; + calls: number; + callFailures: number; + tools: readonly ActivityToolUsage[]; + /** Present only on the day-shaped windows (`7d`, `14d`). */ + days?: readonly ActivityDay[]; + recent: readonly ActivityAttempt[]; + backfillState: ActivityBackfillState; +}>; + +export type ConversationSummary = Readonly<{ + id: string; + familiarId: string; + title?: string; + updatedAt: string; + failed: boolean; + pending: boolean; +}>; + +export type ThreadMessage = Readonly<{ + id: string; + conversationId: string; + parentId: string | null; + role: string; + text: string; + createdAt: string; + attachmentCount: number; + toolCount: number; + isError: boolean; + cancelled: boolean; +}>; + +/** + * Capability names a `FamiliarsSource` may advertise, gating which controls + * the shell renders as enabled. See `./capabilities.ts`. + */ +export type Capability = + | 'familiars' + | 'conversations' + | 'conversation-messages' + | 'familiar-contract' + | 'familiar-analytics' + | 'conversations-write' + | 'runs' + | 'conversation-participants' + | 'attention' + | 'rich-content' + | 'attachments' + | 'familiars-write' + | 'screen'; + +/** Stage 1 (reads) of the Familiars surface data-source seam. */ +export type FamiliarsSource = Readonly<{ + familiars(): Promise>>; + familiar(id: string): Promise>; + activity(id: string, window?: ActivityWindow): Promise>; + conversations(): Promise>>; + messages(conversationId: string): Promise>>; + capabilities(): ReadonlySet; +}>; diff --git a/src/lib/sdk/connection-controller.ts b/src/lib/sdk/connection-controller.ts index 89995242..04cd52ae 100644 --- a/src/lib/sdk/connection-controller.ts +++ b/src/lib/sdk/connection-controller.ts @@ -38,6 +38,8 @@ export type CaveReadClient = Pick< | 'listConversations' | 'getConversation' | 'listConversationMessages' + | 'familiarContract' + | 'familiarAnalytics' >; type CaveConnectionClient = CaveReadClient & diff --git a/src/lib/sdk/query-adapter.test.ts b/src/lib/sdk/query-adapter.test.ts index 41849e3d..2e7e41d2 100644 --- a/src/lib/sdk/query-adapter.test.ts +++ b/src/lib/sdk/query-adapter.test.ts @@ -85,6 +85,17 @@ function createReadClient(overrides: Partial = {}): CaveReadClie ], cursor: { current: 'cursor-messages', hasMore: false }, }), + familiarContract: vi.fn().mockResolvedValue({ + id: 'familiar-1', + present: { soul: true, identity: true, ward: true, memory: true }, + report: { specVersion: '1.0', pass: true, properties: [], violations: [], warnings: [] }, + }), + familiarAnalytics: vi.fn().mockResolvedValue({ + generatedAt: '2026-08-25T00:00:00.000Z', + windows: {}, + recentAttempts: [], + backfill: { state: 'complete', imported: 0 }, + }), ...overrides, }; } @@ -575,6 +586,99 @@ describe('createQueryAdapter', () => { } }); + it('forwards the familiar id for a contract read and caches it within the ttl', async () => { + let now = 1_000; + const client = createReadClient(); + const adapter = createQueryAdapter(() => client, { now: () => now }); + + const first = await adapter.familiarContract('familiar-1'); + const second = await adapter.familiarContract('familiar-1'); + + expect(client.familiarContract).toHaveBeenCalledTimes(1); + expect(client.familiarContract).toHaveBeenCalledWith( + 'familiar-1', + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + expect(second).toBe(first); + expect(first).toMatchObject({ status: 'ok', data: { id: 'familiar-1' } }); + + now += 5_001; + await adapter.familiarContract('familiar-1'); + + expect(client.familiarContract).toHaveBeenCalledTimes(2); + }); + + it('reads a different familiar rather than serving the cached contract of another', async () => { + const client = createReadClient(); + const adapter = createQueryAdapter(() => client); + + await adapter.familiarContract('familiar-1'); + await adapter.familiarContract('familiar-2'); + + expect(client.familiarContract).toHaveBeenCalledTimes(2); + expect(client.familiarContract).toHaveBeenLastCalledWith('familiar-2', expect.anything()); + }); + + it('omits analytics query members that were not supplied', async () => { + const client = createReadClient(); + const adapter = createQueryAdapter(() => client); + + await adapter.familiarAnalytics('familiar-1'); + + // An exact match, not objectContaining: the point is that `window` and + // `recentLimit` are absent rather than forwarded as undefined. + expect(client.familiarAnalytics).toHaveBeenCalledWith('familiar-1', { + signal: expect.any(AbortSignal), + }); + }); + + it('forwards the analytics window and recent limit, keying the cache on both', async () => { + const client = createReadClient(); + const adapter = createQueryAdapter(() => client); + + await adapter.familiarAnalytics('familiar-1', { window: '7d', recentLimit: 5 }); + await adapter.familiarAnalytics('familiar-1', { window: '7d', recentLimit: 5 }); + + expect(client.familiarAnalytics).toHaveBeenCalledTimes(1); + expect(client.familiarAnalytics).toHaveBeenCalledWith( + 'familiar-1', + expect.objectContaining({ window: '7d', recentLimit: 5, signal: expect.any(AbortSignal) }), + ); + + // A different window is a different read, not a cache hit on the first. + await adapter.familiarAnalytics('familiar-1', { window: '14d', recentLimit: 5 }); + + expect(client.familiarAnalytics).toHaveBeenCalledTimes(2); + }); + + it('marks an older analytics read stale once the ready client identity changes', async () => { + const pending = deferred>>(); + const first = createReadClient({ + familiarAnalytics: vi.fn().mockImplementation(() => pending.promise), + }); + const second = createReadClient(); + let current: CaveReadClient = first; + const adapter = createQueryAdapter(() => current); + + const inflight = adapter.familiarAnalytics('familiar-1'); + current = second; + pending.resolve({ + generatedAt: '2026-08-25T00:00:00.000Z', + windows: {}, + recentAttempts: [], + backfill: { state: 'complete', imported: 0 }, + }); + + await expect(inflight).resolves.toEqual({ status: 'stale' }); + }); + + it('returns not_ready for familiar reads when no ready client is available', async () => { + const adapter = createQueryAdapter(() => null); + + await expect(adapter.familiarContract('familiar-1')).resolves.toEqual({ status: 'not_ready' }); + await expect(adapter.familiarAnalytics('familiar-1')).resolves.toEqual({ status: 'not_ready' }); + }); + it('returns frozen immutable snapshots for successful reads and result objects', async () => { const source = { data: [{ id: 'familiar-1', displayName: 'Mara', role: 'Guide' }], diff --git a/src/lib/sdk/query-adapter.ts b/src/lib/sdk/query-adapter.ts index 2a4bcf89..f3ac3d84 100644 --- a/src/lib/sdk/query-adapter.ts +++ b/src/lib/sdk/query-adapter.ts @@ -1,3 +1,8 @@ +import type { + CaveAnalyticsWindowKey, + CaveFamiliarAnalytics, + CaveFamiliarContract, +} from '@opencoven/cave-client'; import { type CaveCanonicalFamiliar, type CaveConversation, @@ -17,6 +22,11 @@ export type QueryResult = | { status: 'error'; code: string } | { status: 'ok'; data: T }; +export type FamiliarAnalyticsQuery = Readonly<{ + window?: CaveAnalyticsWindowKey; + recentLimit?: number; +}>; + export type QueryAdapter = { listFamiliars(options?: PageOptions): Promise>>; listProjects(options?: PageOptions): Promise>>; @@ -26,6 +36,11 @@ export type QueryAdapter = { conversationId: string, options?: PageOptions, ): Promise>>; + familiarContract(familiarId: string): Promise>; + familiarAnalytics( + familiarId: string, + options?: FamiliarAnalyticsQuery, + ): Promise>; invalidate(): void; dispose(): void; }; @@ -37,7 +52,14 @@ export type QueryAdapterOptions = Readonly<{ maxCacheEntries?: number; }>; -type QueryChannel = 'familiars' | 'projects' | 'conversations' | 'conversation-detail' | 'messages'; +type QueryChannel = + | 'familiars' + | 'projects' + | 'conversations' + | 'conversation-detail' + | 'messages' + | 'familiar-contract' + | 'familiar-analytics'; type InflightEntry = Readonly<{ channelGeneration: number; @@ -432,6 +454,26 @@ export function createQueryAdapter( (client, signal) => client.listConversationMessages(conversationId, { ...page, signal }), ); }, + familiarContract(familiarId) { + return runRead('familiar-contract', familiarId, detailTtlMs, (client, signal) => + client.familiarContract(familiarId, { signal }), + ); + }, + familiarAnalytics(familiarId, query) { + const window = query?.window; + const recentLimit = query?.recentLimit; + return runRead( + 'familiar-analytics', + `${familiarId}:${window ?? 'all-windows'}:${recentLimit ?? 'default-recent'}`, + detailTtlMs, + (client, signal) => + client.familiarAnalytics(familiarId, { + ...(window === undefined ? {} : { window }), + ...(recentLimit === undefined ? {} : { recentLimit }), + signal, + }), + ); + }, invalidate, dispose, }); diff --git a/src/main.tsx b/src/main.tsx index 990dd304..706006de 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -8,6 +8,9 @@ import { DemoShell } from './demo/chat-demo'; import './demo/chat-demo.css'; import { FamiliarsShell } from './demo/familiars-shell'; import './demo/familiars-shell.css'; +import { createMockFamiliarsSource } from './familiars/mock-source'; +import { FamiliarsReadsShell } from './familiars/reads-shell'; +import './familiars/reads-shell.css'; import { MinimalMacOS } from './demo/minimal-macos'; import './demo/minimal-macos.css'; import './styles.css'; @@ -20,8 +23,9 @@ if (!rootElement) { } /** - * `?demo=chat`, `?demo=messages`, and `?demo=minimal` render a - * proof-of-concept surface instead of the Phase 1 read-only production app. + * `?demo=chat`, `?demo=messages`, `?demo=minimal`, and `?demo=familiars-reads` + * render a proof-of-concept surface instead of the Phase 1 read-only + * production app. * * A query flag rather than a replacement: the read-only production app is * still what ships, and what the default browser and desktop checks assert. @@ -33,7 +37,12 @@ if (!rootElement) { * at the centre of the chat. `messages` is the earlier Messages-shaped * surface it replaced, kept reachable so the two can be compared side by * side rather than from memory; `minimal` implements the approved "Coven Cave - * Minimal (macOS)" design. + * Minimal (macOS)" design. `familiars-reads` is Stage 1 of the familiars + * integration plan (`docs/superpowers/plans/2026-09-02-familiars-integration.md`): + * a separate, smaller shell rendering only what `FamiliarsSource` can + * honestly serve today, against `MockFamiliarsSource`. It previews the + * eventual production route; `chat`'s richer, Stage 2-4 behavior stays + * demo-only until Cave serves it. */ /** * A demo build opens on a demo surface without a query flag. @@ -54,6 +63,10 @@ function surfaceFor(name: string | null) { return ; } + if (name === 'familiars-reads') { + return ; + } + return name === 'minimal' ? : ; } diff --git a/src/specification-guards.test.ts b/src/specification-guards.test.ts index d6ff42c3..14b34c81 100644 --- a/src/specification-guards.test.ts +++ b/src/specification-guards.test.ts @@ -227,6 +227,8 @@ describe('Phase 1 specification guards', () => { 'allow-cave-list-conversations', 'allow-cave-get-conversation', 'allow-cave-list-conversation-messages', + 'allow-cave-familiar-contract', + 'allow-cave-familiar-analytics', ]); for (const permission of capability.permissions) { @@ -680,6 +682,8 @@ describe('Phase 1 specification guards', () => { 'cave_list_conversations', 'cave_get_conversation', 'cave_list_conversation_messages', + 'cave_familiar_contract', + 'cave_familiar_analytics', ]) { expect(buildScript).toContain(`"${command}"`); } @@ -706,6 +710,8 @@ describe('Phase 1 specification guards', () => { 'cave_list_conversations', 'cave_get_conversation', 'cave_list_conversation_messages', + 'cave_familiar_contract', + 'cave_familiar_analytics', ]; const schemaCommands = [...schema.matchAll(/"const": "(?:allow|deny)-([^"]+)"/g)] .map((match) => match[1]?.replaceAll('-', '_') ?? '') @@ -743,6 +749,8 @@ describe('Phase 1 specification guards', () => { 'cave_list_conversations', 'cave_get_conversation', 'cave_list_conversation_messages', + 'cave_familiar_contract', + 'cave_familiar_analytics', ]; expect(registeredCommandNames(commands)).toEqual(expected);