# SSE Migration Implementation Plan > [!NOTE] > This document may not reflect the current implementation. > See the final report for up-to-date state: > [Final Report](../reports/sse-migration.md) > **For agentic workers:** REQUIRED SUB-SKILL: Use compose:subagent (recommended) or compose:execute to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Replace polling-based message reception with SSE (Server-Sent Events) for real-time message delivery from signal-cli. **Architecture:** Create a new `noise-sse.el` module that implements an SSE client using Emacs process filters. The client connects to signal-cli's `/api/v1/events` endpoint, parses incoming SSE events, and feeds them to the existing envelope processing pipeline. Includes automatic reconnection with exponential backoff. **Tech Stack:** Emacs Lisp, `make-network-process`, `url.el` for URL parsing, existing `noise-receive.el` envelope processing. --- ## File Structure | File | Action | Purpose | |------|--------|---------| | `noise-sse.el` | Create | SSE client with process filter, reconnection logic | | `noise-receive.el` | Modify | Remove polling, add SSE start/stop functions | | `noise.el` | Modify | Update `noise-mode` to use SSE | | `test/noise-sse-test.el` | Create | Unit tests for SSE parsing and reconnection | | `test/noise-receive-test.el` | Modify | Update tests for SSE integration | --- ### Task 1: Create SSE Client Core **Covers:** S1 (SSE connection management) **Files:** - Create: `noise-sse.el` - Test: `test/noise-sse-test.el` - [ ] **Step 1: Write failing test for SSE event parsing** ```elisp ;; test/noise-sse-test.el (require 'ert) (require 'noise-sse) (ert-deftest noise-sse--parse-event-basic () "Parse a basic SSE event with data field." (let ((event "data: {\"type\":\"message\",\"content\":\"hello\"}\n\n")) (let ((result (noise-sse--parse-event event))) (should (hash-table-p result)) (should (equal (gethash "type" result) "message")) (should (equal (gethash "content" result) "hello"))))) (ert-deftest noise-sse--parse-event-multiline () "Parse SSE event with multiline data." (let ((event "data: {\"type\":\"message\"\ndata: \"content\":\"hello\"}\n\n")) (let ((result (noise-sse--parse-event event))) (should (hash-table-p result)) (should (equal (gethash "type" result) "message"))))) (ert-deftest noise-sse--parse-event-empty () "Parse empty SSE event returns nil." (let ((event "\n\n")) (should (null (noise-sse--parse-event event))))) ``` - [ ] **Step 2: Run test to verify it fails** Run: `emacs -batch -l ert -l test/noise-sse-test.el -f ert-run-tests-batch-and-exit` Expected: FAIL with "void-function noise-sse--parse-event" - [ ] **Step 3: Write minimal SSE parsing implementation** ```elisp ;; noise-sse.el --- SSE client for signal-cli -*- lexical-binding: t; -*- ;;; Commentary: ;; Implements Server-Sent Events client for receiving real-time messages ;; from signal-cli daemon's /api/v1/events endpoint. ;;; Code: (require 'url) (require 'json) (require 'cl-lib) (defun noise-sse--parse-event (event-string) "Parse an SSE EVENT-STRING into a hash table. Returns nil for empty or invalid events." (when (and event-string (not (string-empty-p event-string))) (let ((lines (split-string event-string "\n")) (data-lines '())) (dolist (line lines) (when (string-prefix-p "data: " line) (push (substring line 6) data-lines))) (when data-lines (let ((json-str (mapconcat #'identity (nreverse data-lines) ""))) (condition-case nil (json-parse-string json-str) (error nil))))))) (provide 'noise-sse) ;;; noise-sse.el ends here ``` - [ ] **Step 4: Run test to verify it passes** Run: `emacs -batch -l ert -l test/noise-sse-test.el -f ert-run-tests-batch-and-exit` Expected: PASS - [ ] **Step 5: Commit** ```bash git add noise-sse.el test/noise-sse-test.el git commit -m "feat: add SSE event parsing for signal-cli" ``` --- ### Task 2: Add SSE Connection Management **Covers:** S1, S2 (connection lifecycle, reconnection) **Files:** - Modify: `noise-sse.el` - Modify: `test/noise-sse-test.el` - [ ] **Step 1: Write failing tests for connection state** ```elisp ;; Add to test/noise-sse-test.el (ert-deftest noise-sse--initial-state () "SSE client starts in disconnected state." (let ((noise-sse--process nil) (noise-sse--reconnect-timer nil) (noise-sse--reconnect-delay 1)) (should (null noise-sse--process)) (should (null noise-sse--reconnect-timer)) (should (= 1 noise-sse--reconnect-delay)))) (ert-deftest noise-sse--reconnect-delay-backoff () "Reconnect delay doubles on each attempt." (let ((noise-sse--reconnect-delay 1)) (noise-sse--increase-reconnect-delay) (should (= 2 noise-sse--reconnect-delay)) (noise-sse--increase-reconnect-delay) (should (= 4 noise-sse--reconnect-delay)) (noise-sse--increase-reconnect-delay) (should (= 8 noise-sse--reconnect-delay)))) (ert-deftest noise-sse--reconnect-delay-max () "Reconnect delay caps at max value." (let ((noise-sse--reconnect-delay 32) (noise-sse--max-reconnect-delay 60)) (noise-sse--increase-reconnect-delay) (should (= 60 noise-sse--reconnect-delay)))) ``` - [ ] **Step 2: Run test to verify it fails** Run: `emacs -batch -l ert -l test/noise-sse-test.el -f ert-run-tests-batch-and-exit` Expected: FAIL with "void-function noise-sse--increase-reconnect-delay" - [ ] **Step 3: Implement connection state and reconnection logic** ```elisp ;; Add to noise-sse.el after requires (defvar noise-sse--process nil "Active SSE network process, or nil.") (defvar noise-sse--reconnect-timer nil "Timer for reconnection attempts, or nil.") (defvar noise-sse--reconnect-delay 1 "Current delay in seconds before next reconnection attempt.") (defvar noise-sdefcustom noise-sse-max-reconnect-delay 60 "Maximum delay in seconds between reconnection attempts." :type 'integer :group 'noise) (defun noise-sse--increase-reconnect-delay () "Increase reconnect delay with exponential backoff, capped at max." (setq noise-sse--reconnect-delay (min (* noise-sse--reconnect-delay 2) noise-sse-max-reconnect-delay))) (defun noise-sse--reset-reconnect-delay () "Reset reconnect delay to initial value after successful connection." (setq noise-sse--reconnect-delay 1)) ``` - [ ] **Step 4: Run test to verify it passes** Run: `emacs -batch -l ert -l test/noise-sse-test.el -f ert-run-tests-batch-and-exit` Expected: PASS - [ ] **Step 5: Commit** ```bash git add noise-sse.el test/noise-sse-test.el git commit -m "feat: add SSE connection state and reconnection logic" ``` --- ### Task 3: Implement Process Filter for SSE Stream **Covers:** S1, S3 (real-time event processing) **Files:** - Modify: `noise-sse.el` - Modify: `test/noise-sse-test.el` - [ ] **Step 1: Write failing test for process filter** ```elisp ;; Add to test/noise-sse-test.el (ert-deftest noise-sse--filter-processes-complete-event () "Process filter parses and calls handler for complete events." (let ((processed-events '())) (cl-letf (((symbol-function 'noise-sse--handle-event) (lambda (event) (push event processed-events)))) (let ((noise-sse--buffer "")) (noise-sse--filter nil "data: {\"type\":\"message\"}\n\n") (should (= 1 (length processed-events))) (should (hash-table-p (car processed-events))))))) (ert-deftest noise-sse--filter-buffers-incomplete-event () "Process filter buffers incomplete events until newline." (let ((processed-events '())) (cl-letf (((symbol-function 'noise-sse--handle-event) (lambda (event) (push event processed-events)))) (let ((noise-sse--buffer "")) (noise-sse--filter nil "data: {\"type\":\"message\"") (should (= 0 (length processed-events))) (noise-sse--filter nil "}\n\n") (should (= 1 (length processed-events))))))) ``` - [ ] **Step 2: Run test to verify it fails** Run: `emacs -batch -l ert -l test/noise-sse-test.el -f ert-run-tests-batch-and-exit` Expected: FAIL with "void-function noise-sse--filter" - [ ] **Step 3: Implement process filter** ```elisp ;; Add to noise-sse.el (defvar noise-sse--buffer "" "Buffer for partial SSE event data.") (defun noise-sse--filter (proc string) "Process filter for SSE connection. Buffers incoming STRING and processes complete events." (setq noise-sse--buffer (concat noise-sse--buffer string)) (while (string-match "\n\n" noise-sse--buffer) (let ((event-end (match-beginning 0)) (event-start 0)) (let ((event-str (substring noise-sse--buffer event-start event-end))) (setq noise-sse--buffer (substring noise-sse--buffer (+ event-end 2))) (when-let ((event (noise-sse--parse-event event-str))) (noise-sse--handle-event event)))))) (defun noise-sse--handle-event (event) "Handle a parsed SSE EVENT. This function will be overridden in noise-receive to process envelopes." (message "SSE event received: %s" event)) ``` - [ ] **Step 4: Run test to verify it passes** Run: `emacs -batch -l ert -l test/noise-sse-test.el -f ert-run-tests-batch-and-exit` Expected: PASS - [ ] **Step 5: Commit** ```bash git add noise-sse.el test/noise-sse-test.el git commit -m "feat: add SSE process filter for stream parsing" ``` --- ### Task 4: Implement SSE Connection Functions **Covers:** S1, S2 (connection establishment, URL construction) **Files:** - Modify: `noise-sse.el` - Modify: `test/noise-sse-test.el` - [ ] **Step 1: Write failing test for URL construction** ```elisp ;; Add to test/noise-sse-test.el (ert-deftest noise-sse--build-url () "Build SSE endpoint URL from base address." (let ((noise-signal-cli-address "http://localhost:8080") (noise-account "+15551234567")) (let ((url (noise-sse--build-url))) (should (string-match-p "/api/v1/events" url)) (should (string-match-p "account=%2B15551234567" url))))) (ert-deftest noise-sse--build-url-no-account () "Build SSE endpoint URL without account parameter." (let ((noise-signal-cli-address "http://localhost:8080") (noise-account nil)) (let ((url (noise-sse--build-url))) (should (string-match-p "/api/v1/events" url)) (should-not (string-match-p "account=" url))))) ``` - [ ] **Step 2: Run test to verify it fails** Run: `emacs -batch -l ert -l test/noise-sse-test.el -f ert-run-tests-batch-and-exit` Expected: FAIL with "void-function noise-sse--build-url" - [ ] **Step 3: Implement URL construction and connection** ```elisp ;; Add to noise-sse.el (declare-variable noise-signal-cli-address) (declare-variable noise-account) (defun noise-sse--build-url () "Build the SSE endpoint URL for signal-cli." (let ((base (concat noise-signal-cli-address "/api/v1/events"))) (if (and (boundp 'noise-account) noise-account) (concat base "?account=" (url-hexify-string noise-account)) base))) (defun noise-sse--sentinel (proc status) "Process sentinel for SSE connection. Handles connection close and triggers reconnection." (when (or (equal status "finished\n") (string-match-p "failed" status) (string-match-p "deleted" status)) (message "Noise: SSE connection lost, reconnecting in %ds..." noise-sse--reconnect-delay) (setq noise-sse--process nil) (noise-sse--schedule-reconnect))) (defun noise-sse--schedule-reconnect () "Schedule a reconnection attempt after the current delay." (when noise-sse--reconnect-timer (cancel-timer noise-sse--reconnect-timer)) (setq noise-sse--reconnect-timer (run-with-timer noise-sse--reconnect-delay nil #'noise-sse-connect))) ``` - [ ] **Step 4: Run test to verify it passes** Run: `emacs -batch -l ert -l test/noise-sse-test.el -f ert-run-tests-batch-and-exit` Expected: PASS - [ ] **Step 5: Commit** ```bash git add noise-sse.el test/noise-sse-test.el git commit -m "feat: add SSE URL construction and connection management" ``` --- ### Task 5: Implement Connect/Disconnect Functions **Covers:** S1, S2 (public API, connection lifecycle) **Files:** - Modify: `noise-sse.el` - Modify: `test/noise-sse-test.el` - [ ] **Step 1: Write failing test for connect/disconnect** ```elisp ;; Add to test/noise-sse-test.el (ert-deftest noise-sse-connect-creates-process () "noise-sse-connect creates a network process." (let ((noise-signal-cli-address "http://localhost:8080") (noise-account nil) (noise-sse--process nil) (created-process nil)) (cl-letf (((symbol-function 'make-network-process) (lambda (&rest args) (setq created-process t) (make-identity-process "test")))) (noise-sse-connect) (should created-process) (when noise-sse--process (delete-process noise-sse--process))))) (ert-deftest noise-sse-disconnect-cleans-up () "noise-sse-disconnect cleans up process and timer." (let ((noise-sse--process (make-identity-process "test")) (noise-sse--reconnect-timer (run-with-timer 1000 nil #'ignore))) (noise-sse-disconnect) (should (null noise-sse--process)) (should (null noise-sse--reconnect-timer)))) ``` - [ ] **Step 2: Run test to verify it fails** Run: `emacs -batch -l ert -l test/noise-sse-test.el -f ert-run-tests-batch-and-exit` Expected: FAIL with "void-function noise-sse-connect" - [ ] **Step 3: Implement connect/disconnect functions** ```elisp ;; Add to noise-sse.el ;;;###autoload (defun noise-sse-connect () "Connect to signal-cli SSE endpoint." (interactive) (noise-sse-disconnect) (let* ((url (noise-sse--build-url)) (parsed (url-generic-parse-url url)) (host (url-host parsed)) (port (or (url-port parsed) 80)) (proc (make-network-process :name "noise-sse" :host host :port port :filter #'noise-sse--filter :sentinel #'noise-sse--sentinel))) (setq noise-sse--process proc) (noise-sse--reset-reconnect-delay) (process-send-string proc (format "GET %s HTTP/1.1\r\nHost: %s\r\nAccept: text/event-stream\r\n\r\n" (url-filename parsed) host)) (message "Noise: SSE connected to %s" url))) ;;;###autoload (defun noise-sse-disconnect () "Disconnect from signal-cli SSE endpoint." (interactive) (when noise-sse--process (delete-process noise-sse--process) (setq noise-sse--process nil)) (when noise-sse--reconnect-timer (cancel-timer noise-sse--reconnect-timer) (setq noise-sse--reconnect-timer nil)) (setq noise-sse--buffer "") (message "Noise: SSE disconnected")) ``` - [ ] **Step 4: Run test to verify it passes** Run: `emacs -batch -l ert -l test/noise-sse-test.el -f ert-run-tests-batch-and-exit` Expected: PASS - [ ] **Step 5: Commit** ```bash git add noise-sse.el test/noise-sse-test.el git commit -m "feat: add SSE connect/disconnect public API" ``` --- ### Task 6: Integrate SSE with Message Reception **Covers:** S1, S3, S4 (integration with existing envelope processing) **Files:** - Modify: `noise-receive.el` - Modify: `noise-sse.el` - [ ] **Step 1: Add SSE event handler to noise-receive.el** ```elisp ;; Add to noise-receive.el after requires (require 'noise-sse) (defun noise-receive--handle-sse-event (event) "Handle an SSE EVENT from signal-cli. Processes the envelope and updates UI." (noise-db-init) (when-let ((envelope (noise-receive--field event "envelope"))) (when-let ((conv-id (noise-receive--process-envelope envelope))) (noise-conversation-refresh-for conv-id) (when-let ((buf (get-buffer "*signal*"))) (with-current-buffer buf (noise-chat-list--render)))))) ;; Override the default handler in noise-sse (setq noise-sse--handle-event #'noise-receive--handle-sse-event) ``` - [ ] **Step 2: Add SSE start/stop functions** ```elisp ;; Add to noise-receive.el ;;;###autoload (defun noise-receive-start-sse () "Start receiving messages via SSE." (interactive) (noise-sse-connect)) ;;;###autoload (defun noise-receive-stop-sse () "Stop receiving messages via SSE." (interactive) (noise-sse-disconnect)) ``` - [ ] **Step 3: Remove polling functions** ```elisp ;; Remove from noise-receive.el: ;; - noise-receive-interval defcustom ;; - noise-receive--timer defvar ;; - noise-receive--last-poll-error defvar ;; - noise-receive-start-polling function ;; - noise-receive-stop-polling function ;; - noise-receive--poll function ``` - [ ] **Step 4: Update tests** ```elisp ;; Update test/noise-receive-poll-test.el to test SSE integration ;; or rename to test/noise-receive-test.el ``` - [ ] **Step 5: Commit** ```bash git add noise-receive.el noise-sse.el git commit -m "feat: integrate SSE with message reception pipeline" ``` --- ### Task 7: Update Noise Mode **Covers:** S4 (user-facing mode toggle) **Files:** - Modify: `noise.el` - [ ] **Step 1: Update noise-mode to use SSE** ```elisp ;; In noise.el, update noise-mode definition: (define-minor-mode noise-mode "Toggle Noise Signal client mode." :global t :lighter " Noise" :group 'noise (if noise-mode (progn (noise-chat-list) (noise-receive-start-sse)) (noise-receive-stop-sse) (message "Noise mode disabled"))) ``` - [ ] **Step 2: Add SSE configuration options** ```elisp ;; Add to noise.el defcustom section: (defcustom noise-sse-max-reconnect-delay 60 "Maximum delay in seconds between SSE reconnection attempts." :type 'integer :group 'noise) ``` - [ ] **Step 3: Commit** ```bash git add noise.el git commit -m "feat: update noise-mode to use SSE instead of polling" ``` --- ### Task 8: Documentation and Cleanup **Covers:** S5 (documentation updates) **Files:** - Modify: `README.md` - Modify: `AGENTS.md` - [ ] **Step 1: Update README.md** ```markdown ## How it works Noise uses Server-Sent Events (SSE) to receive messages in real-time from signal-cli. When a message arrives, it's instantly processed and displayed. ### SSE Connection - Connects to `signal-cli` at `/api/v1/events` - Automatic reconnection with exponential backoff on disconnect - Configurable via `noise-sse-max-reconnect-delay` ### Manual Refresh You can manually refresh messages with `M-x noise-receive` even while SSE is running. ``` - [ ] **Step 2: Update AGENTS.md** ```markdown ## Architecture - Uses SSE (Server-Sent Events) for real-time message reception - `noise-sse.el` handles connection and event parsing - `noise-receive.el` processes envelopes and updates UI - Automatic reconnection with exponential backoff ``` - [ ] **Step 3: Commit** ```bash git add README.md AGENTS.md git commit -m "docs: update documentation for SSE migration" ``` --- ## Self-Review Checklist - [ ] **Spec coverage:** All design requirements covered by tasks - [ ] **Placeholder scan:** No TBD/TODO/placeholders in steps - [ ] **Type consistency:** Function names and signatures consistent across tasks - [ ] **Test coverage:** Each component has corresponding tests - [ ] **Commit frequency:** Each task has meaningful commit points