diff --git a/app/backend.rkt b/app/backend.rkt index 0be300ee..191d0949 100644 --- a/app/backend.rkt +++ b/app/backend.rkt @@ -21,6 +21,7 @@ (prefix-in rivet-info: rivet/app-info) rivet/backend "core/ai.rkt" + "core/ai-session.rkt" "core/apps.rkt" "core/bindings.rkt" "core/clipboard.rkt" @@ -398,63 +399,51 @@ (define (ai-row id title subtitle arg badge) (list id title subtitle "AI" arg "sparkles" "" badge)) -;; Conversation memory and the last answer. Module-level boxes, NOT -;; parameters: every RVT1 request runs on its own worker thread, and a -;; parameter written in one worker is invisible to the next (this is also -;; why the old parameter-based chat memory never actually survived a -;; round-trip in production, while single-threaded tests stayed green). -;; The streaming thread and later RPCs share these; one semaphore guards -;; the pair against a concurrent reset. -(define ai-memory-lock (make-semaphore 1)) -(define ai-history-box (box '())) -(define ai-last-answer-box (box "")) +;; The product-owned service owns shared memory and worker lifecycle. It is +;; intentionally outside request parameters: every RVT1 request runs on its +;; own worker, while the conversation and an in-flight provider call belong to +;; the application process. +(define ai-service + (make-ai-session-service + #:memory-path ai-memory-path + #:on-start + (lambda () + (state-set! ai-stream (AiStream "running" "" (void)))) + #:on-delta + (lambda (partial) + (state-set! ai-stream (AiStream "running" partial (void)))) + #:on-terminal + (lambda (terminal) + (case (ai-session-terminal-phase terminal) + [(done) + (define answer (ai-session-terminal-text terminal)) + (state-set! ai-stream (AiStream "done" answer (void))) + (ai-finished answer)] + [(error) + (define message (ai-session-terminal-error terminal)) + (state-set! ai-stream (AiStream "error" "" message)) + (notify message)] + [(cancelled) + (state-set! ai-stream (AiStream "idle" "" (void)))])))) (define (ai-history) - (unbox ai-history-box)) + (ai-session-history ai-service)) (define (ai-last-answer) - (unbox ai-last-answer-box)) + (ai-session-last-answer ai-service)) ;; Persistent memory (Raycast-2.0 "Memory" parity): the transcript and the ;; last answer survive restarts. The file is local-only by construction — ;; the same never-synced policy as ai-keys.json, because a transcript is ;; sensitive. (define (ai-restore-memory!) - (with-handlers ([exn:fail? void]) - (define raw (read-json-file (ai-memory-path) (hash))) - (when (hash? raw) - (define hist (hash-ref raw 'history '())) - ;; JSON arrays come back as lists; memory stores pairs. - (when (and (list? hist) - (andmap (lambda (e) - (and (list? e) (= (length e) 2) - (string? (list-ref e 0)) - (string? (list-ref e 1)))) - hist)) - (set-box! ai-history-box - (for/list ([e (in-list hist)]) - (cons (list-ref e 0) (list-ref e 1))))) - (define last (hash-ref raw 'last-answer #f)) - (when (string? last) (set-box! ai-last-answer-box last))))) - -(define (ai-persist-memory!) - ;; Memory stores pairs; JSON needs lists. (The swallow-on-failure here - ;; once hid exactly this: pairs are not jsexpr, so nothing was ever - ;; written and the "persistence" silently saved nothing.) - (with-handlers ([exn:fail? void]) - (write-json-atomic! - (ai-memory-path) - (hasheq 'history (for/list ([e (in-list (ai-history))]) - (list (car e) (cdr e))) - 'last-answer (ai-last-answer))))) + (ai-session-restore! ai-service)) (define (ai-set-history! h) - (set-box! ai-history-box h) - (ai-persist-memory!)) + (ai-session-set-history! ai-service h)) (define (ai-set-last-answer! a) - (set-box! ai-last-answer-box a) - (ai-persist-memory!)) + (ai-session-set-last-answer! ai-service a)) (define (ai-copy-last-answer) (define answer (ai-last-answer)) @@ -606,53 +595,20 @@ (if (pair? items) (clipboard-item-text (car items)) "")) "")) -;; The AI run action: validate, then hand the provider call to a backend -;; thread (ai-request-streaming) and return "started" immediately — the +;; The AI run action: validate, then hand the provider call to the session +;; service and return "started" immediately — the ;; answer streams to hosts through the ai-stream State and lands on the ;; clipboard via ai-finished when complete. run-action therefore never ;; blocks on a model. -(define ai-streaming? (box #f)) - (define (start-ai-stream! chat? raw-arg prompt) - (set-box! ai-streaming? #t) - (state-set! ai-stream (AiStream "running" "" (void))) (define provider (ai-effective-provider)) (define key (ai-get-key (ai-keys-path) provider)) (define model (ai-effective-model)) (define base-url (ai-effective-base-url)) - (define history (if chat? (ai-history) '())) - ;; Delta pushes are State frames; throttle so a fast model cannot flood - ;; the wire (150 ms is well under human reading latency). - (define last-push (box 0.0)) - (thread - (lambda () - (define answer - (ai-request-streaming provider base-url key model prompt - #:history history - #:on-delta - (lambda (partial) - (define now (current-inexact-milliseconds)) - (when (> (- now (unbox last-push)) 150) - (set-box! last-push now) - (with-handlers ([exn:fail? void]) - (state-set! ai-stream - (AiStream "running" partial - (void)))))))) - (set-box! ai-streaming? #f) - (cond - ;; Error strings from ai-request-streaming all start with "AI ". - [(string-prefix? answer "AI ") - (with-handlers ([exn:fail? void]) - (state-set! ai-stream (AiStream "error" "" answer))) - (notify answer)] - [else - (ai-set-last-answer! answer) - (when chat? - (ai-set-history! - (ai-history-append history (string-trim (substring raw-arg 4)) answer))) - (with-handlers ([exn:fail? void]) - (state-set! ai-stream (AiStream "done" answer (void)))) - (ai-finished answer)])))) + (ai-session-start! + ai-service + (ai-session-request provider base-url key model prompt + (and chat? (string-trim (substring raw-arg 4)))))) (define (ai-run! engine arg) (define provider (ai-effective-provider)) @@ -666,32 +622,24 @@ (string-prefix? arg v)) (string=? (string-trim (ai-newest-clipboard-text engine)) "")) (cons "nothing on the clipboard to work on" '())] - [(unbox ai-streaming?) - (cons "AI is already answering — wait for it or ask something new later" '())] [else (define chat? (string-prefix? arg "chat")) (define prompt (if chat? (string-trim (substring arg 4)) (build-ai-prompt arg (ai-newest-clipboard-text engine)))) - (start-ai-stream! chat? arg prompt) - (cons "started" '())])) + (case (start-ai-stream! chat? arg prompt) + [(started) (cons "started" '())] + [(busy) + (cons "AI is already answering — wait for it or ask something new later" '())] + [(closed) (cons "AI service is closed" '())])])) ;; Drop the last chat exchange — the minimal branching primitive: roll ;; the conversation back to try a different direction. (define (ai-undo-exchange!) - (define hist (ai-history)) - (cond - [(null? hist) (cons "nothing to undo" '())] - [else - (define kept - (let loop ([h hist]) - (if (<= (length h) 2) - '() - (cons (car h) (loop (cdr h)))))) - (ai-set-history! kept) - (ai-set-last-answer! "") - (cons "ok" (list (cons 'notify "Undone: dropped the last exchange")))])) + (if (ai-session-undo! ai-service) + (cons "ok" (list (cons 'notify "Undone: dropped the last exchange"))) + (cons "nothing to undo" '()))) (define (ai-act! engine id arg) (case (string->symbol id) @@ -736,10 +684,7 @@ (cons "copied" (list (cons 'paste-to-frontmost answer))) (cons "no AI answer yet" '()))] [(ai.reset) - (call-with-semaphore ai-memory-lock - (lambda () - (ai-set-history! '()) - (ai-set-last-answer! ""))) + (ai-session-reset! ai-service) (cons "ok" (list (cons 'notify "AI conversation reset")))] [else (cons (format "unknown ai action: ~a" id) '())])) @@ -1773,4 +1718,7 @@ (state-set! hotkey (settings-get manager 'hotkey)) (state-set! language (settings-get manager 'language)) (void engine) - (serve-fds in-fd out-fd)) + (dynamic-wind + void + (lambda () (serve-fds in-fd out-fd)) + (lambda () (ai-session-close! ai-service)))) diff --git a/app/core/ai-session.rkt b/app/core/ai-session.rkt new file mode 100644 index 00000000..b09bf0e2 --- /dev/null +++ b/app/core/ai-session.rkt @@ -0,0 +1,337 @@ +#lang racket/base + +;; Product-owned AI session lifecycle. +;; +;; This module deliberately knows nothing about RVT1 or native hosts. It owns +;; exactly one provider worker, the local-only conversation memory, and the +;; rule that every accepted run has at most one terminal result. backend.rkt +;; adapts the callbacks below to the existing AiStream State and ai-finished +;; Event without changing their public wire shapes. Lifecycle callbacks are +;; serialized with start/cancel/close and therefore must not call back into the +;; same service. + +(require racket/list + racket/string + "ai.rkt" + "store.rkt") + +(provide (struct-out ai-session-request) + (struct-out ai-session-terminal) + make-ai-session-service + ai-session-start! + ai-session-cancel! + ai-session-close! + ai-session-closed? + ai-session-running? + ai-session-history + ai-session-last-answer + ai-session-set-history! + ai-session-set-last-answer! + ai-session-reset! + ai-session-undo! + ai-session-restore!) + +;; chat-user is #f for one-shot commands and the user's text for a chat turn. +(struct ai-session-request + (provider base-url key model prompt chat-user) + #:transparent) + +;; phase is one of 'done, 'error, or 'cancelled. Successful text and error +;; text are kept separate so backend adapters never need to reinterpret a +;; partial stream as a terminal result. +(struct ai-session-terminal (phase text error) #:transparent) + +(struct active-run (id custodian worker) #:mutable #:transparent) + +(struct ai-session-service + (gate + memory-path + requester + on-start + on-delta + on-terminal + delta-interval-ms + [closed? #:mutable] + [next-id #:mutable] + [active #:mutable] + [history #:mutable] + [last-answer #:mutable]) + #:transparent) + +(define (make-ai-session-service + #:memory-path memory-path + #:request-streaming [requester ai-request-streaming] + #:on-start [on-start (lambda () (void))] + #:on-delta [on-delta (lambda (_partial) (void))] + #:on-terminal [on-terminal (lambda (_terminal) (void))] + #:delta-interval-ms [delta-interval-ms 150]) + (unless (or (path-string? memory-path) (procedure? memory-path)) + (raise-argument-error 'make-ai-session-service + "path-string? or procedure?" + memory-path)) + (unless (and (real? delta-interval-ms) (>= delta-interval-ms 0)) + (raise-argument-error 'make-ai-session-service + "nonnegative-real?" + delta-interval-ms)) + (ai-session-service (make-semaphore 1) + memory-path + requester + on-start + on-delta + on-terminal + delta-interval-ms + #f 0 #f '() "")) + +(define (with-lock service thunk) + (call-with-semaphore (ai-session-service-gate service) thunk)) + +(define (memory-path service) + (define source (ai-session-service-memory-path service)) + (if (procedure? source) (source) source)) + +(define (valid-history? value) + (and (list? value) + (andmap (lambda (entry) + (and (pair? entry) + (string? (car entry)) + (string? (cdr entry)))) + value))) + +;; Caller holds service.gate. Keeping the write under the same lock preserves +;; write order when an RPC reset races a terminal provider result. +(define (persist-locked! service) + (with-handlers ([exn:fail? void]) + (write-json-atomic! + (memory-path service) + (hasheq 'history + (for/list ([entry (in-list (ai-session-service-history service))]) + (list (car entry) (cdr entry))) + 'last-answer (ai-session-service-last-answer service))))) + +(define (ai-session-history service) + (with-lock service + (lambda () (ai-session-service-history service)))) + +(define (ai-session-last-answer service) + (with-lock service + (lambda () (ai-session-service-last-answer service)))) + +(define (ai-session-set-history! service history) + (unless (valid-history? history) + (raise-argument-error 'ai-session-set-history! + "list of (cons string? string?)" + history)) + (with-lock service + (lambda () + (set-ai-session-service-history! service history) + (persist-locked! service)))) + +(define (ai-session-set-last-answer! service answer) + (unless (string? answer) + (raise-argument-error 'ai-session-set-last-answer! "string?" answer)) + (with-lock service + (lambda () + (set-ai-session-service-last-answer! service answer) + (persist-locked! service)))) + +(define (ai-session-restore! service) + (define raw + (with-handlers ([exn:fail? (lambda (_) (hash))]) + (read-json-file (memory-path service) (hash)))) + (when (hash? raw) + (define json-history (hash-ref raw 'history '())) + (define restored-history + (and (list? json-history) + (andmap (lambda (entry) + (and (list? entry) + (= (length entry) 2) + (string? (car entry)) + (string? (cadr entry)))) + json-history) + (for/list ([entry (in-list json-history)]) + (cons (car entry) (cadr entry))))) + (define restored-answer (hash-ref raw 'last-answer #f)) + (with-lock service + (lambda () + (when restored-history + (set-ai-session-service-history! service restored-history)) + (when (string? restored-answer) + (set-ai-session-service-last-answer! service restored-answer))))) + (void)) + +(define (ai-session-running? service) + (with-lock service + (lambda () (and (ai-session-service-active service) #t)))) + +(define (ai-session-closed? service) + (with-lock service + (lambda () (ai-session-service-closed? service)))) + +(define (safe-callback callback . args) + ;; A missing/disconnected RVT1 peer must not strand the service in running. + (with-handlers ([exn:fail? void]) + (apply callback args))) + +(define (current-run? service run-id) + (and (not (ai-session-service-closed? service)) + (let ([run (ai-session-service-active service)]) + (and run (= run-id (active-run-id run)))))) + +(define (emit-delta-if-current service run-id partial last-push) + (define now (current-inexact-milliseconds)) + (when (> (- now (unbox last-push)) + (ai-session-service-delta-interval-ms service)) + (with-lock service + (lambda () + (when (current-run? service run-id) + ;; Keep the callback inside the lifecycle lock. cancel/close cannot + ;; return while an accepted delta is still being delivered, so hosts + ;; never observe a late running update after a terminal boundary. + (set-box! last-push now) + (safe-callback (ai-session-service-on-delta service) partial)))))) + +(define (answer->terminal answer) + (if (string-prefix? answer "AI ") + (ai-session-terminal 'error "" answer) + (ai-session-terminal 'done answer #f))) + +(define (finish-run! service run-id request history answer) + (define terminal (answer->terminal answer)) + (with-lock service + (lambda () + (when (current-run? service run-id) + (set-ai-session-service-active! service #f) + (when (eq? (ai-session-terminal-phase terminal) 'done) + (set-ai-session-service-last-answer! service answer) + (define chat-user (ai-session-request-chat-user request)) + (when chat-user + (set-ai-session-service-history! + service + (ai-history-append history chat-user answer))) + (persist-locked! service)) + ;; Linearize terminal delivery with close: after close returns, no + ;; terminal callback from this run can still be pending. + (safe-callback (ai-session-service-on-terminal service) terminal))))) + +(define (run-provider! service run-id request history) + (define answer + (with-handlers ([exn:fail? + (lambda (exn) + (string-append "AI request failed: " (exn-message exn)))]) + ((ai-session-service-requester service) + (ai-session-request-provider request) + (ai-session-request-base-url request) + (ai-session-request-key request) + (ai-session-request-model request) + (ai-session-request-prompt request) + #:history history + #:on-delta + (let ([last-push (box 0.0)]) + (lambda (partial) + (emit-delta-if-current service run-id partial last-push)))))) + (finish-run! service run-id request history answer)) + +(define (ai-session-start! service request) + (unless (ai-session-request? request) + (raise-argument-error 'ai-session-start! "ai-session-request?" request)) + (define run #f) + (define history '()) + (define decision + (with-lock service + (lambda () + (cond + [(ai-session-service-closed? service) 'closed] + [(ai-session-service-active service) 'busy] + [else + (define run-id (add1 (ai-session-service-next-id service))) + (set-ai-session-service-next-id! service run-id) + (set! history + (if (ai-session-request-chat-user request) + (ai-session-service-history service) + '())) + (set! run (active-run run-id (make-custodian) #f)) + (set-ai-session-service-active! service run) + ;; Publish running under the same lock as ownership. A concurrent + ;; cancel therefore either follows this transition with cancelled, + ;; or observes no run at all; it cannot be overwritten by a late + ;; running callback. + (safe-callback (ai-session-service-on-start service)) + 'started])))) + (when (eq? decision 'started) + (with-handlers ([exn:fail? + (lambda (exn) + (finish-run! service + (active-run-id run) + request + history + (string-append "AI request failed: " + (exn-message exn))))]) + (define worker + (parameterize ([current-custodian (active-run-custodian run)]) + (thread + (lambda () + (run-provider! service (active-run-id run) request history))))) + (with-lock service + (lambda () + (when (current-run? service (active-run-id run)) + (set-active-run-worker! run worker)))))) + decision) + +(define (ai-session-cancel! service) + (define run + (with-lock service + (lambda () + (define current (ai-session-service-active service)) + (when current (set-ai-session-service-active! service #f)) + current))) + (cond + [run + ;; custodian-shutdown-all waits until its managed worker(s) are dead. + (custodian-shutdown-all (active-run-custodian run)) + (with-lock service + (lambda () + ;; close supersedes cancellation and promises silence after return. + (unless (ai-session-service-closed? service) + (safe-callback (ai-session-service-on-terminal service) + (ai-session-terminal 'cancelled "" #f))))) + #t] + [else #f])) + +(define (ai-session-reset! service) + ;; Cancelling first prevents an in-flight chat from restoring the transcript + ;; after the user has explicitly reset it. + (ai-session-cancel! service) + (with-lock service + (lambda () + (set-ai-session-service-history! service '()) + (set-ai-session-service-last-answer! service "") + (persist-locked! service))) + (void)) + +(define (ai-session-undo! service) + (ai-session-cancel! service) + (with-lock service + (lambda () + (define history (ai-session-service-history service)) + (cond + [(null? history) #f] + [else + (define kept (take history (max 0 (- (length history) 2)))) + (set-ai-session-service-history! service kept) + (set-ai-session-service-last-answer! service "") + (persist-locked! service) + #t])))) + +(define (ai-session-close! service) + (define run + (with-lock service + (lambda () + (define current (ai-session-service-active service)) + ;; Mark closed before tearing down the custodian: callbacks that race + ;; the shutdown observe closed and become no-ops. + (set-ai-session-service-closed?! service #t) + (set-ai-session-service-active! service #f) + current))) + (when run + (custodian-shutdown-all (active-run-custodian run))) + (void)) diff --git a/tests/ai-session-test.rkt b/tests/ai-session-test.rkt new file mode 100644 index 00000000..e72eb82f --- /dev/null +++ b/tests/ai-session-test.rkt @@ -0,0 +1,212 @@ +#lang racket/base + +;; Direct characterization of the product-owned AI session lifecycle. These +;; tests stay below RVT1 so cancellation, shutdown and terminal delivery can be +;; asserted deterministically without a provider or native host. + +(require racket/async-channel + racket/file + racket/string + rackunit + "../app/core/ai-session.rkt") + +(define (with-session-dir thunk) + (define dir (make-temporary-file "fulcrum-ai-session-~a" 'directory)) + (dynamic-wind + void + (lambda () (thunk dir)) + (lambda () + (with-handlers ([exn:fail? void]) + (delete-directory/files dir))))) + +(define (immediate-requester answer [pieces '()]) + (lambda (_provider _base-url _key _model _prompt + #:history [_history '()] + #:on-delta [on-delta void]) + (for ([piece (in-list pieces)]) (on-delta piece)) + answer)) + +(define (mailbox) (make-async-channel)) +(define (mailbox-put box value) (async-channel-put box value)) +(define (mailbox-get/timeout box [timeout 1]) + (sync/timeout timeout box)) + +(test-case "success streams, persists one chat turn, and delivers one terminal" + (with-session-dir + (lambda (dir) + (define starts (mailbox)) + (define deltas (mailbox)) + (define terminals (mailbox)) + (define memory (build-path dir "ai-memory.json")) + (define service + (make-ai-session-service + #:memory-path memory + #:request-streaming (immediate-requester "hello" '("hel" "hello")) + #:delta-interval-ms 0 + #:on-start (lambda () (mailbox-put starts 'running)) + #:on-delta (lambda (partial) (mailbox-put deltas partial)) + #:on-terminal (lambda (terminal) (mailbox-put terminals terminal)))) + (check-equal? + (ai-session-start! + service + (ai-session-request "openai" "https://example.invalid" "secret-key" + "model" "prompt" "question")) + 'started) + (check-equal? (mailbox-get/timeout starts) 'running) + (check-equal? (mailbox-get/timeout deltas) "hel") + (check-equal? (mailbox-get/timeout deltas) "hello") + (define terminal (mailbox-get/timeout terminals)) + (check-equal? terminal (ai-session-terminal 'done "hello" #f)) + (check-false (mailbox-get/timeout terminals 0)) + (check-equal? (ai-session-last-answer service) "hello") + (check-equal? (ai-session-history service) + (list (cons "user" "question") + (cons "assistant" "hello"))) + (define persisted (file->string memory)) + (check-false (string-contains? persisted "secret-key")) + + ;; A fresh service restores only the local transcript and answer. + (define restored (make-ai-session-service #:memory-path memory)) + (ai-session-restore! restored) + (check-equal? (ai-session-history restored) + (ai-session-history service)) + (check-equal? (ai-session-last-answer restored) "hello")))) + +(test-case "provider failure is terminal and never enters conversation memory" + (with-session-dir + (lambda (dir) + (define terminals (mailbox)) + (define service + (make-ai-session-service + #:memory-path (build-path dir "ai-memory.json") + #:request-streaming (immediate-requester "AI request failed: offline") + #:on-terminal (lambda (terminal) (mailbox-put terminals terminal)))) + (check-equal? + (ai-session-start! + service + (ai-session-request "openai" "url" "key" "model" "prompt" "q")) + 'started) + (check-equal? (mailbox-get/timeout terminals) + (ai-session-terminal 'error "" "AI request failed: offline")) + (check-equal? (ai-session-history service) '()) + (check-equal? (ai-session-last-answer service) "")))) + +(test-case "concurrent start is rejected; cancel kills workers and blocks late deltas" + (with-session-dir + (lambda (dir) + (define workers (mailbox)) + (define children (mailbox)) + (define terminals (mailbox)) + (define deltas (mailbox)) + (define captured-delta (box #f)) + (define requester + (lambda (_provider _base-url _key _model _prompt + #:history [_history '()] + #:on-delta [on-delta void]) + (set-box! captured-delta on-delta) + (mailbox-put workers (current-thread)) + (define child (thread (lambda () (sync never-evt)))) + (mailbox-put children child) + (sync never-evt) + "unreachable")) + (define service + (make-ai-session-service + #:memory-path (build-path dir "ai-memory.json") + #:request-streaming requester + #:on-delta (lambda (partial) (mailbox-put deltas partial)) + #:on-terminal (lambda (terminal) (mailbox-put terminals terminal)))) + (define request (ai-session-request "openai" "url" "key" "model" "p" #f)) + (check-equal? (ai-session-start! service request) 'started) + (define worker (mailbox-get/timeout workers)) + (define child (mailbox-get/timeout children)) + (check-true (thread? worker)) + (check-true (thread? child)) + (check-equal? (ai-session-start! service request) 'busy) + (check-true (ai-session-cancel! service)) + (check-equal? (mailbox-get/timeout terminals) + (ai-session-terminal 'cancelled "" #f)) + (check-not-false (sync/timeout 1 (thread-dead-evt worker))) + (check-not-false (sync/timeout 1 (thread-dead-evt child))) + (check-false (ai-session-running? service)) + + ;; Even a stale provider callback retained outside the dead worker cannot + ;; publish after cancellation. + ((unbox captured-delta) "too late") + (check-false (mailbox-get/timeout deltas 0)) + (check-false (mailbox-get/timeout terminals 0)) + (check-false (ai-session-cancel! service))))) + +(test-case "close is silent, kills the worker, and permanently rejects starts" + (with-session-dir + (lambda (dir) + (define workers (mailbox)) + (define children (mailbox)) + (define terminals (mailbox)) + (define captured-delta (box #f)) + (define deltas (mailbox)) + (define service + (make-ai-session-service + #:memory-path (build-path dir "ai-memory.json") + #:request-streaming + (lambda (_provider _base-url _key _model _prompt + #:history [_history '()] + #:on-delta [on-delta void]) + (set-box! captured-delta on-delta) + (mailbox-put workers (current-thread)) + (define child (thread (lambda () (sync never-evt)))) + (mailbox-put children child) + (sync never-evt) + "unreachable") + #:on-delta (lambda (partial) (mailbox-put deltas partial)) + #:on-terminal (lambda (terminal) (mailbox-put terminals terminal)))) + (define request (ai-session-request "ollama" "url" "" "model" "p" #f)) + (check-equal? (ai-session-start! service request) 'started) + (define worker (mailbox-get/timeout workers)) + (define child (mailbox-get/timeout children)) + (ai-session-close! service) + (check-true (ai-session-closed? service)) + (check-not-false (sync/timeout 1 (thread-dead-evt worker))) + (check-not-false (sync/timeout 1 (thread-dead-evt child))) + ((unbox captured-delta) "too late") + (check-false (mailbox-get/timeout deltas 0)) + (check-false (mailbox-get/timeout terminals 0)) + (check-equal? (ai-session-start! service request) 'closed)))) + +(test-case "reset cancels an in-flight chat before clearing persisted memory" + (with-session-dir + (lambda (dir) + (define workers (mailbox)) + (define terminals (mailbox)) + (define memory (build-path dir "ai-memory.json")) + (define service + (make-ai-session-service + #:memory-path memory + #:request-streaming + (lambda (_provider _base-url _key _model _prompt + #:history [_history '()] + #:on-delta [_on-delta void]) + (mailbox-put workers (current-thread)) + (sync never-evt) + "unreachable") + #:on-terminal (lambda (terminal) (mailbox-put terminals terminal)))) + (ai-session-set-history! service + (list (cons "user" "old") + (cons "assistant" "answer"))) + (ai-session-set-last-answer! service "answer") + (check-equal? + (ai-session-start! + service + (ai-session-request "openai" "url" "key" "model" "p" "new")) + 'started) + (define worker (mailbox-get/timeout workers)) + (ai-session-reset! service) + (check-equal? (mailbox-get/timeout terminals) + (ai-session-terminal 'cancelled "" #f)) + (check-not-false (sync/timeout 1 (thread-dead-evt worker))) + (check-equal? (ai-session-history service) '()) + (check-equal? (ai-session-last-answer service) "") + + (define restored (make-ai-session-service #:memory-path memory)) + (ai-session-restore! restored) + (check-equal? (ai-session-history restored) '()) + (check-equal? (ai-session-last-answer restored) ""))))