diff --git a/packages/feature/assistant/src/infrastructure/model-boundary.lisp b/packages/feature/assistant/src/infrastructure/model-boundary.lisp index 30afb06c..6aaf40e0 100644 --- a/packages/feature/assistant/src/infrastructure/model-boundary.lisp +++ b/packages/feature/assistant/src/infrastructure/model-boundary.lisp @@ -8,6 +8,12 @@ kind payload) +(defun make-assistant-model-error-event (generation message) + (make-assistant-model-event + generation + :stream-error + (list (cons "message" (or message "assistant model stream error"))))) + (defstruct (assistant-model-boundary (:constructor %make-assistant-model-boundary (start-fn request-fn poll-fn stop-fn status-fn))) diff --git a/packages/feature/assistant/src/package.lisp b/packages/feature/assistant/src/package.lisp index c24cd4aa..c0f67c3f 100644 --- a/packages/feature/assistant/src/package.lisp +++ b/packages/feature/assistant/src/package.lisp @@ -117,9 +117,11 @@ depend on one stable feature package.") #:assistant-model-event-generation #:assistant-model-event-kind #:assistant-model-event-payload + #:make-assistant-model-error-event #:next-assistant-turn-generation #:make-assistant-user-payload #:assistant-system-init-safe-p #:make-assistant-sidecar-boundary #:assistant-sidecar-command-arguments + #:+assistant-sidecar-response-timeout-seconds+ #:make-assistant-fixture-boundary))) diff --git a/src/infrastructure/acl/syscall-process-sidecar.lisp b/src/infrastructure/acl/syscall-process-sidecar.lisp index a94bb7e0..a7114cde 100644 --- a/src/infrastructure/acl/syscall-process-sidecar.lisp +++ b/src/infrastructure/acl/syscall-process-sidecar.lisp @@ -91,20 +91,20 @@ (if (null handle) (values nil status) (let* ((version nil) + (version-error nil) (reader (sb-thread:make-thread (lambda () (setf version - (handler-case - (with-output-to-string (stream) + (with-output-to-string (stream) + (handler-case (loop for line = (read-line (sidecar-handle-output handle) nil nil) while line - do (write-line line stream))) - (error (condition) - (declare (ignore condition)) - nil))) + do (write-line line stream)) + (error (condition) + (setf version-error (princ-to-string condition)))))) nil) :name "nshell assistant sidecar version reader")) (deadline (+ (get-internal-real-time) @@ -129,12 +129,18 @@ (sb-thread:join-thread reader :default nil :timeout 0.2)) (let ((exit-status (sidecar-exit-status handle))) (stop-sidecar handle) - (values (and version + (values (and version (string-trim '(#\Space #\Tab #\Newline #\Return) version)) (cond (timed-out-p :timeout) ((zerop (or exit-status 1)) :ok) - (t :version-failed)))))))) + (t (list :version-failed + :output (and version + (string-trim + '(#\Space #\Tab #\Newline #\Return) + version)) + :error version-error + :exit-status exit-status))))))))) (defun run-sidecar-version (command) (run-sidecar-version-cancellable command nil)) diff --git a/src/infrastructure/assistant-sidecar-stream.lisp b/src/infrastructure/assistant-sidecar-stream.lisp index 2040ee0b..8d71cf4e 100644 --- a/src/infrastructure/assistant-sidecar-stream.lisp +++ b/src/infrastructure/assistant-sidecar-stream.lisp @@ -51,7 +51,9 @@ (when (and (eql generation (assistant-sidecar-state-startup-generation state)) (eq handle (assistant-sidecar-state-handle state))) - (assistant-sidecar-state-pending state)))) + (let ((pending (assistant-sidecar-state-pending state))) + (values pending + (and pending (assistant-pending-cell-generation pending))))))) (defun %assistant-sidecar-set-pending (state pending) (sb-thread:with-mutex ((assistant-sidecar-state-lock state)) @@ -79,17 +81,15 @@ (error () nil)))) (defun %assistant-sidecar-error-event (generation message) - (make-assistant-model-event - generation - :stream-error - (list (cons "message" (or message "assistant sidecar stream error"))))) + (make-assistant-model-error-event generation message)) (defun %assistant-sidecar-reader-loop (state handle generation) (loop (multiple-value-bind (object status message) (%assistant-read-json-value (nshell.infrastructure.acl:sidecar-handle-output handle)) - (let ((pending (%assistant-sidecar-reader-pending state handle generation))) + (multiple-value-bind (pending pending-generation) + (%assistant-sidecar-reader-pending state handle generation) (unless pending (return)) (case status @@ -101,11 +101,11 @@ (%assistant-sidecar-publish pending (%assistant-sidecar-error-event - (assistant-pending-cell-generation pending) + pending-generation "assistant sidecar returned an unknown event")) (%assistant-sidecar-complete pending)) (let ((event (make-assistant-model-event - (assistant-pending-cell-generation pending) + pending-generation kind object))) (%assistant-sidecar-publish pending event) @@ -122,9 +122,10 @@ (%assistant-sidecar-publish pending (%assistant-sidecar-error-event - (assistant-pending-cell-generation pending) + pending-generation "assistant sidecar process exited before the pending request completed")) (%assistant-sidecar-complete pending)) + (%assistant-sidecar-stop-state state :preserve-pending-p t) (return)) (otherwise (when (and (eq status :error) @@ -137,7 +138,7 @@ (%assistant-sidecar-publish pending (%assistant-sidecar-error-event - (assistant-pending-cell-generation pending) + pending-generation message)) (%assistant-sidecar-complete pending)) (return))))))) @@ -183,7 +184,8 @@ (error () nil)))) (defun %assistant-sidecar-join-thread (thread) - (when thread + (when (and thread + (not (eq thread sb-thread:*current-thread*))) (ignore-errors (sb-thread:join-thread thread :default nil :timeout 0.1)) (when (sb-thread:thread-alive-p thread) @@ -236,13 +238,14 @@ (%assistant-sidecar-close-channel (assistant-pending-cell-events pending)))) -(defun %assistant-sidecar-stop-state (state &key preserve-pending-p) +(defun %assistant-sidecar-stop-state (state &key preserve-pending-p preserve-starting-p) (let (pending write-channel handle startup reader writer error-thread) (sb-thread:with-mutex ((assistant-sidecar-state-lock state)) - (setf (assistant-sidecar-state-starting-p state) nil - (assistant-sidecar-state-startup-generation state) - (1+ (assistant-sidecar-state-startup-generation state)) - pending (assistant-sidecar-state-pending state) + (unless preserve-starting-p + (setf (assistant-sidecar-state-starting-p state) nil + (assistant-sidecar-state-startup-generation state) + (1+ (assistant-sidecar-state-startup-generation state)))) + (setf pending (assistant-sidecar-state-pending state) write-channel (assistant-sidecar-state-write-channel state) handle (assistant-sidecar-state-handle state) startup (assistant-sidecar-state-start-thread state) @@ -445,47 +448,61 @@ :preserve-pending-p t)))))))))))) (defun %assistant-sidecar-start (state) - (if (sb-thread:with-mutex ((assistant-sidecar-state-lock state)) - (assistant-sidecar-state-starting-p state)) - t - (if (null (assistant-sidecar-state-command state)) - (progn - (sb-thread:with-mutex ((assistant-sidecar-state-lock state)) - (setf (assistant-sidecar-state-starting-p state) nil - (assistant-sidecar-state-disabled-reason state) - :disabled-by-environment)) - nil) - (let ((handle (%assistant-sidecar-handle state)) - (init-p (%assistant-sidecar-init-p state)) - (dead-p (sb-thread:with-mutex ((assistant-sidecar-state-lock state)) - (assistant-sidecar-state-dead-p state)))) - (if (and handle init-p (not dead-p) - (nshell.infrastructure.acl:sidecar-alive-p handle)) - t - (progn - (%assistant-sidecar-stop-state state) - (let ((startup-generation - (sb-thread:with-mutex ((assistant-sidecar-state-lock state)) - (setf (assistant-sidecar-state-starting-p state) t - (assistant-sidecar-state-disabled-reason state) nil - (assistant-sidecar-state-dead-p state) nil - (assistant-sidecar-state-dead-reason state) nil) - (incf (assistant-sidecar-state-startup-generation state)))) - (pending - (%make-assistant-pending-cell - 0 - (cl-concurrent-kit:make-channel :buffer-size 128) - (cl-concurrent-kit:make-promise)))) - (%assistant-sidecar-set-pending state pending) - (let ((thread (sb-thread:make-thread - (lambda () - (unwind-protect - (%assistant-sidecar-start-worker - state pending startup-generation) - (%assistant-sidecar-set-start-thread state nil))) - :name "nshell assistant sidecar startup"))) - (%assistant-sidecar-set-start-thread state thread)) - t))))))) + (let ((startup-generation + (sb-thread:with-mutex ((assistant-sidecar-state-lock state)) + (cond + ((assistant-sidecar-state-starting-p state) :already-starting) + ((null (assistant-sidecar-state-command state)) + (setf (assistant-sidecar-state-disabled-reason state) + :disabled-by-environment) + :disabled) + (t + (setf (assistant-sidecar-state-starting-p state) t + (assistant-sidecar-state-disabled-reason state) nil + (assistant-sidecar-state-dead-p state) nil + (assistant-sidecar-state-dead-reason state) nil) + (incf (assistant-sidecar-state-startup-generation state))))))) + (case startup-generation + (:already-starting t) + (:disabled nil) + (otherwise + (let ((handle (%assistant-sidecar-handle state)) + (init-p (%assistant-sidecar-init-p state)) + (dead-p (sb-thread:with-mutex ((assistant-sidecar-state-lock state)) + (assistant-sidecar-state-dead-p state)))) + (if (and handle init-p (not dead-p) + (nshell.infrastructure.acl:sidecar-alive-p handle)) + (progn + (sb-thread:with-mutex ((assistant-sidecar-state-lock state)) + (setf (assistant-sidecar-state-starting-p state) nil)) + t) + (progn + (let ((handoff-complete-p nil)) + (unwind-protect + (progn + (%assistant-sidecar-stop-state state :preserve-starting-p t) + (let ((pending + (%make-assistant-pending-cell + 0 + (cl-concurrent-kit:make-channel :buffer-size 128) + (cl-concurrent-kit:make-promise)))) + (%assistant-sidecar-set-pending state pending) + (let ((thread (sb-thread:make-thread + (lambda () + (unwind-protect + (%assistant-sidecar-start-worker + state pending startup-generation) + (%assistant-sidecar-set-start-thread + state nil))) + :name "nshell assistant sidecar startup"))) + (%assistant-sidecar-set-start-thread state thread) + (setf handoff-complete-p t))) + t) + (unless handoff-complete-p + (sb-thread:with-mutex ((assistant-sidecar-state-lock state)) + (when (eql startup-generation + (assistant-sidecar-state-startup-generation state)) + (setf (assistant-sidecar-state-starting-p state) nil))))))))))))) (defun %assistant-sidecar-request (state generation payload) (let ((handle (%assistant-sidecar-handle state)) diff --git a/src/infrastructure/assistant-sidecar.lisp b/src/infrastructure/assistant-sidecar.lisp index 93f7e026..0cb5a103 100644 --- a/src/infrastructure/assistant-sidecar.lisp +++ b/src/infrastructure/assistant-sidecar.lisp @@ -3,6 +3,9 @@ (defparameter +assistant-sidecar-handshake-timeout-seconds+ 10 "Maximum time allowed for sidecar version and init handshakes.") +(defparameter +assistant-sidecar-response-timeout-seconds+ 90 + "Maximum silence allowed between response events; long generations may run longer.") + (defstruct (assistant-sidecar-state (:constructor %make-assistant-sidecar-state (command arguments))) command diff --git a/src/presentation/repl-agent.lisp b/src/presentation/repl-agent.lisp index e481a1ab..d1d4bdd8 100644 --- a/src/presentation/repl-agent.lisp +++ b/src/presentation/repl-agent.lisp @@ -67,6 +67,7 @@ (setf *agent-session* nil *assistant-model-event-handler* nil *assistant-turn-started-at* nil + *assistant-last-event-at* nil *assistant-request-kind* nil *assistant-command-origin* :typed *assistant-command-confirmed-p* nil diff --git a/src/presentation/repl-ai.lisp b/src/presentation/repl-ai.lisp index 113af4b7..58f0aa33 100644 --- a/src/presentation/repl-ai.lisp +++ b/src/presentation/repl-ai.lisp @@ -5,6 +5,7 @@ (nshell.feature.assistant:next-assistant-turn-generation *assistant-turn-generation*) *assistant-turn-started-at* nil + *assistant-last-event-at* nil *last-assistant-model-event* nil *assistant-model-event-handler* nil *assistant-request-kind* nil diff --git a/src/presentation/repl-assistant-poll.lisp b/src/presentation/repl-assistant-poll.lisp index e40c7db7..11c19ba3 100644 --- a/src/presentation/repl-assistant-poll.lisp +++ b/src/presentation/repl-assistant-poll.lisp @@ -1,10 +1,27 @@ ;;; REPL polling bridge for assistant model events (in-package #:nshell.presentation) +(defun %assistant-response-deadline-expired-p (now) + (and *assistant-last-event-at* + (>= (- now *assistant-last-event-at*) + (* nshell.feature.assistant:+assistant-sidecar-response-timeout-seconds+ + internal-time-units-per-second)))) + (defun %poll-assistant-model-event () - (let ((result - (nshell.feature.assistant:assistant-model-poll - *assistant-turn-generation*))) + (let* ((now (boundary-monotonic)) + (result + (if (%assistant-response-deadline-expired-p now) + (progn + (nshell.feature.assistant:assistant-model-stop) + (list :status :event + :value + (nshell.feature.assistant:make-assistant-model-error-event + *assistant-turn-generation* + (format nil + "AI 応答がタイムアウトしました(~D 秒)" + nshell.feature.assistant:+assistant-sidecar-response-timeout-seconds+)))) + (nshell.feature.assistant:assistant-model-poll + *assistant-turn-generation*)))) (when (eq :event (nshell.feature.assistant:assistant-boundary-status result)) (let ((event (nshell.feature.assistant:assistant-boundary-value result))) @@ -12,4 +29,5 @@ (eql *assistant-turn-generation* (nshell.feature.assistant:assistant-model-event-generation event))) + (setf *assistant-last-event-at* now) event))))) diff --git a/src/presentation/repl-output-handlers.lisp b/src/presentation/repl-output-handlers.lisp index a8bd6030..cd26b202 100644 --- a/src/presentation/repl-output-handlers.lisp +++ b/src/presentation/repl-output-handlers.lisp @@ -224,6 +224,7 @@ wrapped line's other rows on screen as stale duplicates." (clear-rendered-transient-panel) (setf *assistant-model-event-handler* nil *assistant-turn-started-at* nil + *assistant-last-event-at* nil *assistant-request-kind* nil *assistant-explain-candidates* nil *assistant-explain-candidate-index* 0 @@ -341,6 +342,7 @@ wrapped line's other rows on screen as stale duplicates." (setf *input-state* (make-repl-input-state) *assistant-model-event-handler* nil *assistant-turn-started-at* nil + *assistant-last-event-at* nil *assistant-request-kind* nil *assistant-explain-candidates* nil *assistant-explain-candidate-index* 0 @@ -389,7 +391,8 @@ wrapped line's other rows on screen as stale duplicates." (multiple-value-bind (status classification reason) (%assistant-proposal-assessment proposal) (setf *assistant-model-event-handler* nil - *assistant-turn-started-at* nil) + *assistant-turn-started-at* nil + *assistant-last-event-at* nil) (clear-rendered-transient-panel) (if (eq status :parse-error) (progn @@ -459,7 +462,6 @@ wrapped line's other rows on screen as stale duplicates." (setf *assistant-last-cancel-at* nil) (setf *assistant-explain-candidates* nil *assistant-explain-candidate-index* 0) - (setf *assistant-turn-started-at* (boundary-monotonic)) (unless (%assistant-boundary-ok-p start-result) (return-from %process-ask-submit-output-event (%return-from-ask-with-message @@ -480,6 +482,8 @@ wrapped line's other rows on screen as stale duplicates." (format nil "AI 要求を送信できません: ~a" (%assistant-boundary-failure-message request-result "assistant model request failed"))))) + (setf *assistant-turn-started-at* (boundary-monotonic)) + (setf *assistant-last-event-at* *assistant-turn-started-at*) (setf *assistant-model-event-handler* #'%handle-ask-model-event) (render-prompt-cont) (render-assistant-progress-panel *assistant-turn-started-at*) @@ -540,6 +544,7 @@ wrapped line's other rows on screen as stale duplicates." (setf *input-state* (make-repl-input-state) *assistant-model-event-handler* nil *assistant-turn-started-at* nil + *assistant-last-event-at* nil *assistant-request-kind* nil *assistant-explain-candidates* nil *assistant-explain-candidate-index* 0 diff --git a/src/presentation/repl-session-init.lisp b/src/presentation/repl-session-init.lisp index dda22217..92d182bf 100644 --- a/src/presentation/repl-session-init.lisp +++ b/src/presentation/repl-session-init.lisp @@ -159,6 +159,7 @@ entered during this session." *input-state* (make-repl-input-state) *assistant-turn-generation* 0 *assistant-turn-started-at* nil + *assistant-last-event-at* nil *assistant-last-cancel-at* nil *last-assistant-model-event* nil *assistant-model-event-handler* nil diff --git a/src/presentation/repl-state-data.lisp b/src/presentation/repl-state-data.lisp index a81448c4..d2bb6043 100644 --- a/src/presentation/repl-state-data.lisp +++ b/src/presentation/repl-state-data.lisp @@ -15,6 +15,7 @@ (defvar *input-state* nil) (defvar *assistant-turn-generation* 0) (defvar *assistant-turn-started-at* nil) +(defvar *assistant-last-event-at* nil) (defvar *assistant-last-cancel-at* nil) (defconstant +assistant-cancel-window-ticks+ (* 2 internal-time-units-per-second)) diff --git a/t/support/repl.lisp b/t/support/repl.lisp index 471d83bb..a6456085 100644 --- a/t/support/repl.lisp +++ b/t/support/repl.lisp @@ -24,6 +24,7 @@ (nshell.presentation::*input-state* nil) (nshell.presentation::*assistant-turn-generation* 0) (nshell.presentation::*assistant-turn-started-at* nil) + (nshell.presentation::*assistant-last-event-at* nil) (nshell.presentation::*assistant-last-cancel-at* nil) (nshell.presentation::*last-assistant-model-event* nil) (nshell.presentation::*assistant-model-event-handler* nil) diff --git a/t/unit/test-assistant-model-boundary.lisp b/t/unit/test-assistant-model-boundary.lisp index d61cfb1e..ed2a8a22 100644 --- a/t/unit/test-assistant-model-boundary.lisp +++ b/t/unit/test-assistant-model-boundary.lisp @@ -3,7 +3,7 @@ (defun %assistant-stop-sidecar (handle) (funcall (symbol-function 'nshell.infrastructure.acl:stop-sidecar) handle)) -(defun %assistant-test-sidecar-script (&optional one-shot-p) +(defun %assistant-test-sidecar-script (&optional one-shot-p received-path) (let ((path (merge-pathnames (format nil "nshell-assistant-sidecar-~D.sh" (get-internal-real-time)) @@ -17,8 +17,15 @@ "fi" "printf '%s\\n' '{\"type\":\"system\",\"subtype\":\"init\",\"tools\":[\"StructuredOutput\"],\"mcp_servers\":[]}'" "while IFS= read -r line" - "do" - " printf '%s\\n' '{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"ok\"}]}}'" + "do")) + (write-line line stream)) + (when received-path + (write-line + (format nil " printf '%s\\n' \"$line\" >> '~a'" + (namestring received-path)) + stream)) + (dolist (line + '(" printf '%s\\n' '{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"ok\"}]}}'" " printf '%s\\n' '{\"type\":\"result\",\"subtype\":\"success\",\"structured_output\":{\"command\":\"pwd\",\"reason\":\"fixture\",\"risk\":\"low\"}}'")) (write-line line stream)) (when one-shot-p @@ -81,18 +88,64 @@ (sb-posix:chmod (namestring path) #o700) path)) +(defun %assistant-test-term-ignoring-sidecar-script () + (let ((path (merge-pathnames + (format nil "nshell-assistant-term-ignoring-~D.sh" + (get-universal-time)) + (uiop:temporary-directory)))) + (with-open-file (stream path :direction :output :if-exists :supersede) + (dolist (line '("#!/bin/sh" + "trap '' TERM" + "while :; do sleep 1; done")) + (write-line line stream))) + (sb-posix:chmod (namestring path) #o700) + path)) + +(defun %assistant-test-failing-version-sidecar-script () + (let ((path (merge-pathnames + (format nil "nshell-assistant-failing-version-~D.sh" + (get-universal-time)) + (uiop:temporary-directory)))) + (with-open-file (stream path :direction :output :if-exists :supersede) + (dolist (line '("#!/bin/sh" + "printf '%s\\n' 'version probe stdout'" + "printf '%s\\n' 'version probe stderr' >&2" + "exit 17")) + (write-line line stream))) + (sb-posix:chmod (namestring path) #o700) + path)) + (defun %assistant-test-live-thread-named-p (name) (some (lambda (thread) (and (equal name (sb-thread:thread-name thread)) (sb-thread:thread-alive-p thread))) (sb-thread:list-all-threads))) -(defun %assistant-test-await-state (state &optional (limit 240)) - (loop repeat limit - for status = (nshell.feature.assistant:assistant-model-status) - do (if (eq state (getf status :state)) - (return status) - (sleep 0.05)))) +(defmacro %assistant-test-with-sidecar-cleanup (&body body) + `(unwind-protect + (progn ,@body) + (ignore-errors + (nshell.feature.assistant:assistant-model-stop)) + (let ((deadline (+ (get-internal-real-time) + (round (* 2 internal-time-units-per-second))))) + (dolist (name '("nshell assistant sidecar startup" + "nshell assistant sidecar reader" + "nshell assistant sidecar writer" + "nshell assistant sidecar error reader")) + (loop while (and (%assistant-test-live-thread-named-p name) + (< (get-internal-real-time) deadline)) + do (sleep 0.01)))))) + +(defun %assistant-test-await-state (state &optional (timeout-seconds 12)) + (let ((deadline (+ (get-internal-real-time) + (round (* timeout-seconds internal-time-units-per-second)))) + (status nil)) + (loop while (< (get-internal-real-time) deadline) + do (setf status (nshell.feature.assistant:assistant-model-status)) + (when (eq state (getf status :state)) + (return status)) + (sleep 0.05)) + status)) (defun %assistant-test-terminal-event-description (event) (let* ((kind (nshell.feature.assistant:assistant-model-event-kind event)) @@ -265,6 +318,48 @@ (expect "low" :to-equal (second (member "--effort" arguments :test #'string=))))) + (it "captures-pending-generation-with-reader-pending-state" + (let* ((state (nshell.feature.assistant::%make-assistant-sidecar-state + nil nil)) + (handle (gensym "HANDLE-")) + (pending (nshell.feature.assistant::%make-assistant-pending-cell + 17 nil nil))) + (setf (nshell.feature.assistant::assistant-sidecar-state-handle state) + handle + (nshell.feature.assistant::assistant-sidecar-state-pending state) + pending) + (multiple-value-bind (actual actual-generation) + (nshell.feature.assistant::%assistant-sidecar-reader-pending + state handle 0) + (expect t :to-be (eq pending actual)) + (expect 17 :to-be actual-generation)))) + + (it "clears-starting-state-when-startup-handoff-fails" + (let ((state (nshell.feature.assistant::%make-assistant-sidecar-state + "/bin/false" nil)) + (stop-calls 0)) + (unwind-protect + (with-temporary-function + ('nshell.feature.assistant::%assistant-sidecar-stop-state + (lambda (state &key preserve-pending-p preserve-starting-p) + (declare (ignore state preserve-pending-p preserve-starting-p)) + (incf stop-calls) + (when (= stop-calls 1) + (error "injected startup handoff failure")))) + (expect t :to-be + (handler-case + (progn + (nshell.feature.assistant::%assistant-sidecar-start state) + nil) + (error () t))) + (expect nil :to-be + (nshell.feature.assistant::%assistant-sidecar-starting-p state)) + (expect t :to-be + (nshell.feature.assistant::%assistant-sidecar-start state)) + (expect 2 :to-be stop-calls)) + (ignore-errors + (nshell.feature.assistant::%assistant-sidecar-stop-state state))))) + (it "disables-sidecar-when-requested-by-environment" (let ((old-value (host-kit:getenv "NSHELL_AI_DISABLE"))) (unwind-protect @@ -304,6 +399,31 @@ (expect nil :to-be (search (string #\Newline) (getf status :reason))) (nshell.feature.assistant:assistant-model-stop)) + (%assistant-test-with-sidecar-cleanup) + (when (probe-file script) + (delete-file script))))) + + (it "reports-version-probe-output-when-the-sidecar-exits-with-an-error" + (let ((script (%assistant-test-failing-version-sidecar-script))) + (unwind-protect + (let* ((boundary (nshell.feature.assistant:make-assistant-sidecar-boundary + :command (namestring script))) + (nshell.feature.assistant:*assistant-boundaries* + (nshell.feature.assistant:make-assistant-boundary-context boundary)) + (result (nshell.feature.assistant:assistant-model-start)) + (status nil) + (reason nil)) + (expect :ok :to-be + (nshell.feature.assistant:assistant-boundary-status result)) + (setf status (%assistant-test-await-state :unavailable) + reason (getf status :reason)) + (expect :unavailable :to-be (getf status :state)) + (expect (search "version probe stdout" reason) :to-be-truthy) + (expect (search "version probe stderr" reason) :to-be-truthy) + (expect (search "17" reason) + :to-be-truthy) + (nshell.feature.assistant:assistant-model-stop)) + (%assistant-test-with-sidecar-cleanup) (when (probe-file script) (delete-file script))))) @@ -369,35 +489,72 @@ (expect :ok :to-be (nshell.feature.assistant:assistant-boundary-status (nshell.feature.assistant:assistant-model-stop)))) + (%assistant-test-with-sidecar-cleanup) (when (probe-file script) (delete-file script))))) (it "discards-a-stale-sidecar-pending-before-the-next-request" - (let ((script (%assistant-test-sidecar-script))) + (with-temporary-output-file (received-path :prefix "nshell-assistant-received-") + (let ((script (%assistant-test-sidecar-script nil received-path))) + (unwind-protect + (let* ((boundary + (nshell.feature.assistant:make-assistant-sidecar-boundary + :command (namestring script))) + (nshell.feature.assistant:*assistant-boundaries* + (nshell.feature.assistant:make-assistant-boundary-context + boundary))) + (expect :ok :to-be + (nshell.feature.assistant:assistant-boundary-status + (nshell.feature.assistant:assistant-model-start))) + (expect :ok :to-be + (nshell.feature.assistant:assistant-boundary-status + (nshell.feature.assistant:assistant-model-request + 1 '(("message" . "stale"))))) + (expect :empty :to-be + (nshell.feature.assistant:assistant-boundary-status + (nshell.feature.assistant:assistant-model-poll 2))) + (expect :ok :to-be + (nshell.feature.assistant:assistant-boundary-status + (nshell.feature.assistant:assistant-model-request + 2 '(("message" . "fresh"))))) + (let ((deadline (+ (get-internal-real-time) + (round (* 2 internal-time-units-per-second)))) + (received "")) + (loop while (and (not (search "fresh" received)) + (< (get-internal-real-time) deadline)) + do (setf received + (if (probe-file received-path) + (uiop:read-file-string received-path) + "")) + (sleep 0.01)) + (expect nil :to-be (search "stale" received)) + (expect (search "fresh" received) :to-be-truthy)) + (expect :ok :to-be + (nshell.feature.assistant:assistant-boundary-status + (nshell.feature.assistant:assistant-model-stop)))) + (%assistant-test-with-sidecar-cleanup) + (when (probe-file script) + (delete-file script)))))) + + (it "kills-a-sidecar-that-ignores-sigterm" + (let ((script (%assistant-test-term-ignoring-sidecar-script)) + (handle nil)) (unwind-protect - (let* ((boundary - (nshell.feature.assistant:make-assistant-sidecar-boundary - :command (namestring script))) - (nshell.feature.assistant:*assistant-boundaries* - (nshell.feature.assistant:make-assistant-boundary-context - boundary))) - (expect :ok :to-be - (nshell.feature.assistant:assistant-boundary-status - (nshell.feature.assistant:assistant-model-start))) - (expect :ok :to-be - (nshell.feature.assistant:assistant-boundary-status - (nshell.feature.assistant:assistant-model-request - 1 '(("message" . "stale"))))) - (expect :empty :to-be - (nshell.feature.assistant:assistant-boundary-status - (nshell.feature.assistant:assistant-model-poll 2))) - (expect :ok :to-be - (nshell.feature.assistant:assistant-boundary-status - (nshell.feature.assistant:assistant-model-request - 2 '(("message" . "fresh"))))) - (expect :ok :to-be - (nshell.feature.assistant:assistant-boundary-status - (nshell.feature.assistant:assistant-model-stop)))) + (multiple-value-bind (started-handle status) + (nshell.infrastructure.acl:spawn-sidecar + (namestring script) nil + :input :stream :output :stream :error :stream) + (setf handle started-handle) + (expect :started :to-be status) + (expect t :to-be + (nshell.infrastructure.acl:process-alive-p + (nshell.infrastructure.acl::sidecar-handle-process handle))) + (expect t :to-be (%assistant-stop-sidecar handle)) + (expect nil :to-be + (nshell.infrastructure.acl:process-alive-p + (nshell.infrastructure.acl::sidecar-handle-process handle)))) + (when handle + (%assistant-stop-sidecar handle)) (when (probe-file script) (delete-file script))))) @@ -471,6 +628,7 @@ (expect :ok :to-be (nshell.feature.assistant:assistant-boundary-status (nshell.feature.assistant:assistant-model-stop)))) + (%assistant-test-with-sidecar-cleanup) (when (probe-file script) (delete-file script)))))) @@ -518,6 +676,7 @@ (expect :ok :to-be (nshell.feature.assistant:assistant-boundary-status (nshell.feature.assistant:assistant-model-stop)))) + (%assistant-test-with-sidecar-cleanup) (when (probe-file script) (delete-file script))))) @@ -557,6 +716,7 @@ (nshell.feature.assistant:assistant-model-event-payload event) :test #'string=))))) + (%assistant-test-with-sidecar-cleanup) (when (probe-file script) (delete-file script))))) @@ -603,6 +763,7 @@ (nshell.feature.assistant:assistant-model-event-kind error-event))) (nshell.feature.assistant:assistant-model-stop)) + (%assistant-test-with-sidecar-cleanup) (when (probe-file script) (delete-file script))))) @@ -650,5 +811,6 @@ (expect nil :to-be (%assistant-test-live-thread-named-p "nshell assistant sidecar reader"))) + (%assistant-test-with-sidecar-cleanup) (when (probe-file script) (delete-file script)))))) diff --git a/t/unit/test-repl-assistant-poll.lisp b/t/unit/test-repl-assistant-poll.lisp index 99a6fbd1..a9710240 100644 --- a/t/unit/test-repl-assistant-poll.lisp +++ b/t/unit/test-repl-assistant-poll.lisp @@ -1,6 +1,81 @@ (in-package #:nshell/test) (describe "repl-assistant-poll-tests" + (it "does-not-time-out-while-response-events-continue" + (with-repl-test-state + (let ((clock 0) + (stop-count 0) + (poll-count 0) + (clocks (list 0 0 + (round (* 0.5 internal-time-units-per-second)) + (round (* 0.5 internal-time-units-per-second)) + internal-time-units-per-second + internal-time-units-per-second + (round (* 1.5 internal-time-units-per-second)) + (round (* 1.5 internal-time-units-per-second))))) + (setf nshell.presentation::*assistant-turn-generation* 7 + nshell.presentation::*assistant-turn-started-at* 0 + nshell.presentation::*assistant-last-event-at* 0 + nshell.feature.assistant:*assistant-boundaries* + (nshell.feature.assistant:make-assistant-boundary-context + (nshell.feature.assistant:make-assistant-model-boundary + :start-fn (lambda () t) + :request-fn (lambda (generation payload) + (declare (ignore generation payload)) t) + :poll-fn (lambda (generation) + (declare (ignore generation)) + (incf poll-count) + (values + (nshell.feature.assistant::make-assistant-model-event + 7 :assistant nil) + t)) + :stop-fn (lambda () (incf stop-count) t) + :status-fn (lambda () '(:state :ready))))) + (let ((nshell.feature.assistant:+assistant-sidecar-response-timeout-seconds+ 1)) + (with-temporary-functions + (('nshell.presentation::boundary-monotonic + (lambda () (setf clock (pop clocks))))) + (dotimes (index 4) + (declare (ignore index)) + (expect (nshell.feature.assistant:assistant-model-event-p + (nshell.presentation::%poll-assistant-model-event)) + :to-be-truthy)) + (expect stop-count :to-be 0) + (expect poll-count :to-be 4)))))) + + (it "stops-and-delivers-a-terminal-event-after-response-silence" + (with-repl-test-state + (let ((clock internal-time-units-per-second) + (stop-count 0) + (poll-count 0)) + (setf nshell.presentation::*assistant-turn-generation* 7 + nshell.presentation::*assistant-turn-started-at* 0 + nshell.presentation::*assistant-last-event-at* 0 + nshell.feature.assistant:*assistant-boundaries* + (nshell.feature.assistant:make-assistant-boundary-context + (nshell.feature.assistant:make-assistant-model-boundary + :start-fn (lambda () t) + :request-fn (lambda (generation payload) + (declare (ignore generation payload)) t) + :poll-fn (lambda (generation) + (declare (ignore generation)) + (incf poll-count) + (values nil nil)) + :stop-fn (lambda () (incf stop-count) t) + :status-fn (lambda () '(:state :ready))))) + (let ((nshell.feature.assistant:+assistant-sidecar-response-timeout-seconds+ 1)) + (with-temporary-functions + (('nshell.presentation::boundary-monotonic (lambda () clock))) + (let ((event (nshell.presentation::%poll-assistant-model-event))) + (expect poll-count :to-be 0) + (expect stop-count :to-be 1) + (expect (nshell.feature.assistant:assistant-model-event-kind event) + :to-be :stream-error) + (expect (cdr (assoc "message" + (nshell.feature.assistant:assistant-model-event-payload event) + :test #'string=)) + :to-equal "AI 応答がタイムアウトしました(1 秒)"))))))) + (it "delivers-a-current-generation-model-event-through-the-repl-loop" (with-repl-test-state (let* ((event