From 0e5d6ac36f0c44afe0461953a216254e7342bccd Mon Sep 17 00:00:00 2001 From: Texas-walker15 Date: Thu, 3 Sep 2026 10:42:17 +0200 Subject: [PATCH 1/2] feat: add phase 2 chat foundation --- README.md | 64 ++-- docs/phase-2-chat-architecture.md | 156 +++++++++ src/main/secrets.ts | 29 +- src/renderer/App.tsx | 21 +- src/renderer/chat/Chat.tsx | 128 ++++++++ src/renderer/chat/conversation-controller.ts | 220 +++++++++++++ src/renderer/chat/useConversation.ts | 51 +++ src/renderer/styles.css | 130 ++++++++ src/shared/chat/index.ts | 19 ++ src/shared/chat/mock-provider.ts | 140 ++++++++ src/shared/chat/provider.ts | 84 +++++ src/shared/constants.ts | 69 ++++ src/shared/schemas/chat.schema.ts | 85 +++++ src/shared/schemas/index.ts | 3 + tests/unit/main/secrets.test.ts | 10 + .../renderer/conversation-controller.test.ts | 310 ++++++++++++++++++ tests/unit/shared/chat.schema.test.ts | 176 ++++++++++ tests/unit/shared/mock-provider.test.ts | 151 +++++++++ 18 files changed, 1805 insertions(+), 41 deletions(-) create mode 100644 docs/phase-2-chat-architecture.md create mode 100644 src/renderer/chat/Chat.tsx create mode 100644 src/renderer/chat/conversation-controller.ts create mode 100644 src/renderer/chat/useConversation.ts create mode 100644 src/shared/chat/index.ts create mode 100644 src/shared/chat/mock-provider.ts create mode 100644 src/shared/chat/provider.ts create mode 100644 src/shared/schemas/chat.schema.ts create mode 100644 tests/unit/renderer/conversation-controller.test.ts create mode 100644 tests/unit/shared/chat.schema.test.ts create mode 100644 tests/unit/shared/mock-provider.test.ts diff --git a/README.md b/README.md index e790498..c02dc60 100644 --- a/README.md +++ b/README.md @@ -3,27 +3,25 @@ A local-first, permission-controlled desktop assistant for Windows. The assistant is named **JARVIS** by default; the product is **Local Agent**. -> **Status: Phase 1, Milestone 8 (final Phase 1 milestone).** -> The repository contains project scaffolding, shared schemas, a hardened -> Electron desktop shell, non-secret settings storage, an audit-log -> foundation, the permission-policy runtime, persisted emergency-stop state, -> the first-run onboarding interface, a provider-settings foundation, and an -> encrypted secret store for API keys. The application registers its first -> real, privileged IPC channels (settings and secrets), each gated by the -> same permission engine, executor and audit log every action type uses, and -> confirmed through a real native dialog for the sensitive ones. Milestone 8 -> adds a GitHub Actions CI workflow that runs the full verification pipeline -> — type-check, lint, format check, unit tests, build, the Playwright/Electron -> end-to-end smoke suite and `npm audit` — on `windows-latest`, and closes a -> handful of documentation inaccuracies found during a full review (a stale -> "asynchronous safeStorage" claim in `.env.example` and the ADR, and a stale -> "audit log not yet called from the running application" claim that Milestone -> 7 had already made untrue). There is still no model integration: no -> provider is ever called, and no key is ever validated over the network. -> Nothing in this repository makes a network request or performs an action on -> your machine beyond writing your own settings and encrypted key locally — -> the renderer's Content-Security-Policy blocks outbound network access -> outright, which the end-to-end test suite asserts. +> **Status: Phase 1 complete. Phase 2, Milestone 1 (chat foundation) in progress.** +> Phase 1 delivered the hardened desktop shell, non-secret settings storage, +> an audit-log foundation, the permission-policy runtime, persisted +> emergency-stop state, first-run onboarding, an encrypted secret store, and +> CI. Phase 2 Milestone 1 adds a **mock-only chat foundation**: a validated +> chat message and conversation model, a provider-independent +> `ChatProvider` interface, a deterministic mock provider, and a chat surface +> in the renderer with empty, loading, error and retry states. **No real +> model provider is called. No network request is made anywhere in this +> milestone** — the mock provider lives entirely inside `src/shared`, where +> the same lint boundary that blocks Electron and Node access also blocks +> `fetch`, `XMLHttpRequest`, `WebSocket` and `EventSource` as globals. Chat +> requires **no new IPC channel**: it never calls `window.localAgent`, so +> nothing a chat message contains can reach a privileged API, a file, or the +> permission engine. See +> [docs/phase-2-chat-architecture.md](docs/phase-2-chat-architecture.md) for +> the full design and for what remains explicitly deferred (real providers, +> tool/action execution from model output, memory, and everything else Phase +> 2 has not reached yet). All rights reserved. No licence has been granted for this project. @@ -91,9 +89,17 @@ postponed. - `hasApiKey`, the only secret-related value the interface ever sees, is reconciled against the encrypted store on every read and write — the store is the source of truth, never the cached flag. +- Chat (Phase 2, Milestone 1) makes no network request and adds no IPC + channel. The mock provider lives under `src/shared/chat`, where the same + lint boundary that keeps `src/shared` free of Electron and Node access also + blocks `fetch`, `XMLHttpRequest`, `WebSocket` and `EventSource` as globals. + Assistant text is rendered as plain JSX text, never as HTML, and is never + treated as authorization for anything — there is no action for it to + authorize, since chat never calls `window.localAgent`. Full detail, including known limitations, is in -[docs/security-model.md](docs/security-model.md). +[docs/security-model.md](docs/security-model.md); the chat-specific design is +in [docs/phase-2-chat-architecture.md](docs/phase-2-chat-architecture.md). ## Where your data lives @@ -158,8 +164,11 @@ platform this project does not ship on. ## Repository layout ``` -src/shared/ Pure schemas, types and constants. No I/O, no Electron. - Safe to import from any process, including the renderer. +src/shared/ Pure schemas, types and constants, plus chat/ (the + provider-independent ChatProvider interface and the + deterministic mock provider). No I/O, no Electron, no + network — safe to import from any process, including the + renderer. src/main/ Privileged Electron main process. Owns the BrowserWindow, the Content-Security-Policy, navigation/window-open/webview hardening, non-secret settings storage (paths.ts, @@ -178,8 +187,11 @@ src/preload/ The single contextBridge. Exposes a narrow, explicitly invoke-any-channel function. Bundled into one file: a sandboxed preload cannot require() local modules at runtime. src/renderer/ React interface: App.tsx gates on onboardingCompleted, - Onboarding.tsx is the first-run form. No Node, no Electron, - no filesystem access — only the bridge at window.localAgent. + Onboarding.tsx is the first-run form, chat/ is the Phase 2 + chat surface (Chat.tsx, useConversation.ts and the + framework-independent conversation-controller.ts). No Node, + no Electron, no filesystem access — only the bridge at + window.localAgent, which chat never calls. tests/unit/ Unit tests. `npm test`. tests/e2e/ Playwright + Electron smoke test against the built app. `npm run test:e2e`. diff --git a/docs/phase-2-chat-architecture.md b/docs/phase-2-chat-architecture.md new file mode 100644 index 0000000..952ed13 --- /dev/null +++ b/docs/phase-2-chat-architecture.md @@ -0,0 +1,156 @@ +# Phase 2 chat architecture + +> **Current state.** Phase 2, Milestone 1 implements a **mock-only chat +> foundation**. Every control below marked **[implemented]** is real and +> tested; everything marked **[deferred]** does not exist yet and this +> milestone does not lay groundwork that quietly enables it. Nothing here +> makes a network request, and nothing here can trigger a privileged action. + +--- + +## Summary + +| Property | State | +| ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| Chat message and conversation model | **[implemented]** | +| Deterministic mock provider | **[implemented]** | +| Real model provider (GLM, OpenAI-compatible, Ollama) | **[deferred]** — no network call exists anywhere in this codebase | +| API-key retrieval for a provider call | **[deferred]** — chat never reads the secret store | +| Tool calls / action execution from model output | **[deferred]** — there is no tool type a model can invoke, and no execution path from chat to `main/executor.ts` | +| Model output treated as authorization | **Never.** There is no mechanism by which it could be — chat has no IPC channel at all | +| Network request in this milestone | **None.** See [Security boundary](#security-boundary) | +| New privileged IPC channel | **None added.** Chat is entirely renderer-local | + +--- + +## Message model + +`src/shared/schemas/chat.schema.ts` defines `chatMessageSchema`: + +| Field | Type | Notes | +| ----------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `id` | UUID string | Generated by `crypto.randomUUID()` in the renderer's `ConversationController`, never by the schema itself — the schema only validates the shape. | +| `role` | `'system' \| 'user' \| 'assistant' \| 'tool'` | `tool` is declared for forward compatibility with a later milestone's tool-call support and is never produced by anything in this milestone — the mock provider never returns one, and nothing constructs one. | +| `content` | bounded string | 1–8,000 characters. Rejects unsafe control characters (NUL and other C0/C1 controls) while allowing tab, newline and carriage return, since chat content is legitimately multi-line — unlike the single-line `displayString` used for settings identity fields. Also rejects Unicode bidirectional override/isolate characters, the same "Trojan Source" defence `settings.schema.ts` already applies to display strings, extended here because assistant output is untrusted text a human reads. | +| `createdAt` | UTC ISO-8601, no offset | Supplied by the caller, exactly like every other timestamp in this codebase (`src/shared` has no clock access — see `docs/architecture.md`'s "Timestamps and clocks" section). | +| `status` | optional `'complete' \| 'error'` | Declared on the schema now, **unused by this milestone's actual logic** — see [Conversation state](#conversation-state) for why a failed attempt does not become an `'error'`-status message. Exists so a later milestone (for example, a streaming provider marking a message `'streaming'` while it fills in) is a new enum member, not a new field requiring a schema-version bump. | +| `metadata` | optional bounded JSON object | Reuses `auditParametersSchema` — the exact schema audit records already use — rather than a second implementation. Depth, breadth, size and key length are all bounded, and any field whose name looks capable of carrying a credential (`apiKey`, `token`, `secret`, …) is rejected unless its value is already `[REDACTED]`, the same redaction contract the audit log enforces. **A message can never carry a secret in its metadata**, structurally, not by convention. | + +`chatMessageSchema` is `z.strictObject` — an unrecognised field is rejected outright, matching every other persisted or validated shape in this codebase. `createChatMessage(input)` validates and returns a message, or throws; nothing downstream ever sees an unvalidated message. + +**Nothing about a message is persisted to disk in this milestone.** Conversation state lives only in renderer memory for the lifetime of the window. `CHAT_CONVERSATION_MAX_MESSAGES` (200, in `src/shared/constants.ts`) bounds memory growth, not a file format. + +--- + +## Provider interface + +`src/shared/chat/provider.ts` defines `ChatProvider`: + +```ts +interface ChatProvider { + readonly id: string; + send( + request: { readonly messages: readonly ChatMessage[] }, + options?: { readonly signal?: AbortSignal }, + ): Promise<{ readonly content: string }>; +} +``` + +Deliberately minimal and provider-independent: + +- **No Electron dependency, no filesystem or shell access.** The type itself cannot express either — there is nothing in the interface a provider could use to reach one. +- **No network access is possible from an implementation placed under `src/shared`.** `eslint.config.js`'s purity boundary — the same rule that blocks Electron and every Node built-in throughout `src/shared` — also blocks `fetch`, `XMLHttpRequest`, `WebSocket` and `EventSource` as bare globals. A future edit that tried to add a real network call to a provider living in this directory would fail lint, not merely fail review. +- **Cancellation is a plain `AbortSignal`** — the standard, dependency-free mechanism. Nothing here starts a timer or a request that would need a bespoke cancellation channel; this is a real, exercised feature (see the mock provider's abort handling below), not a decorative type. +- **No provider implemented here is real.** The only implementation in this codebase is the deterministic mock. Adding a real provider (GLM, an OpenAI-compatible endpoint, Ollama) is explicitly out of scope for this milestone and is not designed here beyond the interface shape — in particular, _how_ a future real provider would use an API key without a plaintext key ever reaching this layer is an open question for whichever milestone adds one, not answered by this one. + +## Mock provider + +`src/shared/chat/mock-provider.ts`'s `createMockChatProvider()` is the only `ChatProvider` in this codebase. + +- **Deterministic.** The reply is templated from the conversation alone — the latest user message, truncated to 200 characters and echoed into a fixed sentence. The same conversation always produces the same reply. +- **No network request, no secret access.** It reads nothing from settings or the secret store; its only input is the `messages` array it is handed. +- **Clearly marked.** Every reply begins with `[Mock provider]` and states plainly that no real model is connected and no network request was made. +- **Simulated latency, not randomness.** `delayMs` (default 400ms) makes the loading state actually visible in the UI; tests pass `delayMs: 0` for an instant, still-deterministic result, or use fake timers to assert the loading state mid-delay. +- **A documented, deterministic failure trigger.** Sending the exact message `/mock-fail` (`MOCK_CHAT_PROVIDER_FAILURE_TRIGGER`) makes the provider reject with a `ChatProviderError('PROVIDER_REQUEST_FAILED', …)` instead of responding. This exists so the failure and retry UI can be exercised — by tests and during manual review — without needing a real, flaky failure to occur. +- **Injectable.** Tests construct their own `ChatProvider` implementations (see `tests/unit/renderer/conversation-controller.test.ts`) to control exactly when and how a response resolves, independent of the mock's own timing. + +--- + +## Conversation state + +`src/renderer/chat/conversation-controller.ts`'s `ConversationController` is **framework-independent** — no React import, no DOM dependency beyond standard globals (`crypto.randomUUID`, `AbortController`, `setTimeout`) available in both a browser and Vitest's `node` test environment. This mirrors the pure-core-behind-a-thin-adapter split already used elsewhere in this codebase (`decidePermission`, `main/settings-service.ts`), and it is what makes every conversation behaviour unit-testable with plain Vitest — no React Testing Library, no jsdom, neither of which is part of this project's toolchain (see `docs/security-model.md`, known limitation 18). + +`useConversation.ts` is the thin React binding: it owns no logic, only a `useState`/`useEffect` subscription to the controller. + +**State shape:** + +```ts +interface ConversationState { + readonly messages: readonly ChatMessage[]; + readonly status: 'idle' | 'awaiting-response'; + readonly error: { readonly message: string } | null; +} +``` + +**The message list is append-only.** Once added, a message is never mutated or removed — on success, on failure, or on retry. Loading and error state live _outside_ the message array entirely, which is the design decision behind `ChatMessage.status` going unused in this milestone: a failed attempt never becomes an `'error'`-status message sitting in the transcript. Instead: + +- **Submit** trims the input, validates it through `createChatMessage`, appends the user message, sets `status: 'awaiting-response'`, and calls the provider. A no-op for a blank message, and a no-op while a response is already pending — the duplicate-submission guard. +- **On success**, the assistant's reply is validated through the same `createChatMessage` and appended; `status` returns to `'idle'`. +- **On failure** (a thrown `ChatProviderError` or any other exception), **no assistant message is added.** The user's message stays exactly where it was; `status` returns to `'idle'`; `error` is set to a generic, safe message. A raw `Error.message` from an unknown exception is never forwarded — an assistant reply is untrusted output, and symmetrically, so is whatever a provider implementation throws. +- **Retry** re-sends the existing conversation without adding a second copy of the last user message, and is a no-op unless idle with a standing error. +- **A conversation at `CHAT_CONVERSATION_MAX_MESSAGES`** refuses further submission with an explanatory error, without contacting the provider. +- **Cancellation.** Each request gets its own `AbortController`; `dispose()` (called from `useConversation`'s unmount cleanup) aborts any in-flight request and stops delivering further state updates — the same purpose the `cancelled` boolean flag already serves around `App.tsx`'s async effects, backed here by a real signal since `ChatProvider.send` accepts one. + +--- + +## UI + +`src/renderer/chat/Chat.tsx`, mounted from `App.tsx`'s `'ready'` phase, replacing the previous static welcome screen with the assistant's name and status line as a compact header above the chat surface. + +- **Empty state** — a friendly prompt when no messages exist yet, explicitly naming the mock provider. +- **Message list** (`role="log"`, `aria-live="polite"`) — each message a bubble labelled by role. +- **Loading state** — an `aria-live` "…is thinking" line while `status === 'awaiting-response'`; the composer is disabled for the same duration, which is the actual duplicate-submission prevention the user sees (the controller enforces it structurally regardless). +- **Error state** (`role="alert"`) with a **Retry** button, shown only when `state.error` is set. +- **Composer** — a `