APK turn cancel + session resume + history replay

ober

591af44a984b9fbd2322542d238e3cdb783b6698

diff --git a/android/app/src/main/java/dev/jerboa/jcode/JcodeClient.kt b/android/app/src/main/java/dev/jerboa/jcode/JcodeClient.kt
index 1f06541..f12f07d 100644
--- a/android/app/src/main/java/dev/jerboa/jcode/JcodeClient.kt
+++ b/android/app/src/main/java/dev/jerboa/jcode/JcodeClient.kt
@@ -29,11 +29,16 @@ class JcodeClient(private val context: Context) {
     interface Listener {
         fun onConnected()
         fun onReady(sessionId: String, title: String)
+        fun onHistoryUser(content: String)
+        fun onHistoryAssistant(content: String)
+        fun onHistoryTool(name: String, argsJson: String)
+        fun onHistoryEnd()
         fun onToken(text: String)
         fun onToolStart(name: String, argsJson: String)
         fun onToolEnd(name: String)
         fun onTurnEnd(sessionId: String)
         fun onError(message: String)
+        fun onCancelled()
         fun onDisconnected(reason: String)
         fun onAuthFailed(message: String)
         fun onConfig(provider: String, model: String, providers: List<String>)
@@ -221,6 +226,10 @@ class JcodeClient(private val context: Context) {
         send(JSONObject().put("type", "get_status"))
     }
 
+    fun sendCancel() {
+        send(JSONObject().put("type", "cancel"))
+    }
+
     // ── internal ────────────────────────────────────────────────────────
 
     private fun writerLoop(writer: BufferedWriter) {
@@ -268,6 +277,20 @@ class JcodeClient(private val context: Context) {
                     obj.optString("session_id", ""),
                     obj.optString("session_title", "")
                 )
+                "history" -> {
+                    val role = obj.optString("role", "")
+                    val content = obj.optString("content", "")
+                    when (role) {
+                        "user" -> l.onHistoryUser(content)
+                        "assistant" -> l.onHistoryAssistant(content)
+                    }
+                }
+                "history_tool" -> {
+                    val argsRaw = obj.opt("args")
+                    val argsStr = argsRaw?.toString() ?: "{}"
+                    l.onHistoryTool(obj.optString("name", "?"), argsStr)
+                }
+                "history_end" -> l.onHistoryEnd()
                 "token" -> l.onToken(obj.optString("text", ""))
                 "tool_start" -> l.onToolStart(
                     obj.optString("name", "?"),
@@ -275,6 +298,7 @@ class JcodeClient(private val context: Context) {
                 )
                 "tool_end" -> l.onToolEnd(obj.optString("name", "?"))
                 "turn_end" -> l.onTurnEnd(obj.optString("session_id", ""))
+                "cancelled" -> l.onCancelled()
                 "error" -> l.onError(obj.optString("message", "unknown error"))
                 "config" -> {
                     val providers = mutableListOf<String>()
diff --git a/android/app/src/main/java/dev/jerboa/jcode/MainActivity.kt b/android/app/src/main/java/dev/jerboa/jcode/MainActivity.kt
index 6a6a935..e708e95 100644
--- a/android/app/src/main/java/dev/jerboa/jcode/MainActivity.kt
+++ b/android/app/src/main/java/dev/jerboa/jcode/MainActivity.kt
@@ -153,10 +153,12 @@ class MainActivity : Activity(), JcodeClient.Listener {
         btnProvider.setOnClickListener { showProviderPicker() }
         btnModel.setOnClickListener { showModelPicker() }
 
-        btnSend.setOnClickListener { trySend() }
+        btnSend.setOnClickListener {
+            if (sending) tryCancel() else trySend()
+        }
         inputText.setOnEditorActionListener { _, actionId, _ ->
             if (actionId == EditorInfo.IME_ACTION_SEND) {
-                trySend()
+                if (!sending) trySend()
                 true
             } else false
         }
@@ -257,7 +259,20 @@ class MainActivity : Activity(), JcodeClient.Listener {
 
     private fun setInputEnabled(enabled: Boolean) {
         inputText.isEnabled = enabled
-        btnSend.isEnabled = enabled && !sending
+        // Send button stays clickable while sending so the user can hit Stop.
+        btnSend.isEnabled = enabled
+    }
+
+    private fun setSending(active: Boolean) {
+        sending = active
+        if (active) {
+            btnSend.text = getString(R.string.stop)
+            btnSend.backgroundTintList = resources.getColorStateList(R.color.mode_plan, theme)
+        } else {
+            btnSend.text = getString(R.string.send)
+            btnSend.backgroundTintList = resources.getColorStateList(R.color.accent, theme)
+        }
+        btnSend.isEnabled = client.isConnected()
     }
 
     private fun appendUser(text: String) {
@@ -387,8 +402,7 @@ class MainActivity : Activity(), JcodeClient.Listener {
             return
         }
 
-        sending = true
-        btnSend.isEnabled = false
+        setSending(true)
         appendUser(text)
         // Reset tool count for new turn
         toolCount = 0
@@ -399,6 +413,14 @@ class MainActivity : Activity(), JcodeClient.Listener {
         inputText.text = Editable.Factory.getInstance().newEditable("")
     }
 
+    private fun tryCancel() {
+        if (!sending) return
+        if (!client.isConnected()) return
+        client.sendCancel()
+        setStatus(getString(R.string.status_cancelling))
+        // Server will reply with {cancelled} + {turn_end}; UI state resets there.
+    }
+
     // ── JcodeClient.Listener (main thread) ──────────────────────────────
 
     override fun onConnected() {
@@ -410,10 +432,40 @@ class MainActivity : Activity(), JcodeClient.Listener {
     }
 
     override fun onReady(sessionId: String, title: String) {
+        // Server may follow `ready` with `history` events to repopulate the
+        // chat. Clear stale views first so reconnecting to the same session
+        // (banner reconnect or app relaunch) doesn't double-render messages.
+        chatContainer.removeAllViews()
+        toolsContainer.removeAllViews()
+        toolCount = 0
+        updateTabLabels()
+        currentAssistantText = null
+        currentAssistantBuf.setLength(0)
+
         if (title.isNotEmpty()) titleText.text = title
         setStatus("Ready \u00b7 session ${sessionId.take(8)}")
     }
 
+    override fun onHistoryUser(content: String) {
+        appendUser(content)
+    }
+
+    override fun onHistoryAssistant(content: String) {
+        // Replay: render the full content as a non-streaming assistant bubble.
+        val view = layoutInflater.inflate(R.layout.item_chat_assistant, chatContainer, false)
+        view.findViewById<TextView>(R.id.message_text).text = content
+        chatContainer.addView(view)
+        throttledScroll()
+    }
+
+    override fun onHistoryTool(name: String, argsJson: String) {
+        appendTool(name, argsJson)
+    }
+
+    override fun onHistoryEnd() {
+        throttledScroll()
+    }
+
     override fun onToken(text: String) {
         appendAssistantToken(text)
     }
@@ -430,16 +482,19 @@ class MainActivity : Activity(), JcodeClient.Listener {
     override fun onTurnEnd(sessionId: String) {
         finalizeAssistant()
         hideStatus()
-        sending = false
-        btnSend.isEnabled = true
+        setSending(false)
     }
 
     override fun onError(message: String) {
         appendError(message)
         finalizeAssistant()
         hideStatus()
-        sending = false
-        btnSend.isEnabled = true
+        setSending(false)
+    }
+
+    override fun onCancelled() {
+        // Render an inline marker so the user sees the turn was interrupted.
+        appendError("(interrupted)")
     }
 
     override fun onDisconnected(reason: String) {
@@ -449,7 +504,7 @@ class MainActivity : Activity(), JcodeClient.Listener {
         infoRow.visibility = View.GONE
         finalizeAssistant()
         hideStatus()
-        sending = false
+        setSending(false)
     }
 
     override fun onAuthFailed(message: String) {
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index 8371db1..1987e9a 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -3,6 +3,8 @@
     <string name="app_name">jcode</string>
     <string name="settings">Settings</string>
     <string name="send">Send</string>
+    <string name="stop">Stop</string>
+    <string name="status_cancelling">Cancelling\u2026</string>
     <string name="mode_plan">Plan</string>
     <string name="mode_build">Build</string>
     <string name="hint_message">Message jcode\u2026</string>
diff --git a/src/jcode/core/agent.ss b/src/jcode/core/agent.ss
index a46b992..b24a3cb 100644
--- a/src/jcode/core/agent.ss
+++ b/src/jcode/core/agent.ss
@@ -1050,6 +1050,11 @@ Be concise. Prefer edit over write for modifying existing files.
 
 (def (agent-run session-id user-input)
   (log-info logger "agent-run" `((session . ,session-id)))
+  ;; Defensively repair the session before the new turn — a previously
+  ;; cancelled or crashed turn may have left an assistant tool_calls
+  ;; message without matching tool result messages, which the
+  ;; OpenAI-style API rejects with a 400.
+  (try (session-repair-orphan-tool-calls! session-id) (catch (_) (void)))
   (let ((existing (session-get-messages session-id)))
     (when (null? existing)
       (session-add-message session-id (make-system-message (system-prompt)))))
@@ -1218,12 +1223,37 @@ Be concise. Prefer edit over write for modifying existing files.
           (when cb (cb 'end name args))
           (make-tool-result (tool-call-id tc) result))))))
 
+(def *tools-incompat-warned* (make-hash-table))
+
+(def (auto-swap-incompatible-model provider-name model)
+  ;; If the chosen model can't accept the tools API, swap to the provider's
+  ;; default tool-capable model and warn ONCE per (provider,model) pair.
+  ;; Without this, deepseek-reasoner silently strips tools and the model
+  ;; spirals through hallucinated text-form calls.
+  (cond
+    ((not (model-rejects-tools? model)) model)
+    (else
+     (let ((replacement (config-default-model provider-name))
+           (key (string-append provider-name "/" model)))
+       (cond
+         ((or (not replacement) (model-rejects-tools? replacement)) model)
+         (else
+          (unless (hash-get *tools-incompat-warned* key)
+            (hash-put! *tools-incompat-warned* key #t)
+            (log-warn logger "model-no-tools-auto-swap"
+              `((from . ,model) (to . ,replacement) (provider . ,provider-name)))
+            (fprintf (current-error-port)
+              "[jcode] Model '~a' does not support tool calling — using '~a' instead.\n"
+              model replacement))
+          replacement))))))
+
 (def (get-current-provider)
   (let* ((provider-name (or (current-provider-override) (config-provider)))
          (api-key (config-get-provider-key provider-name))
-         (model (or (current-model-override)
-                    (config-ref "model")
-                    (config-default-model provider-name))))
+         (configured (or (current-model-override)
+                         (config-ref "model")
+                         (config-default-model provider-name)))
+         (model (auto-swap-incompatible-model provider-name configured)))
     (make-provider provider-name api-key model)))
 
 (def (agent-chat user-input)
diff --git a/src/jcode/core/log.ss b/src/jcode/core/log.ss
index b01549b..d04bee2 100644
--- a/src/jcode/core/log.ss
+++ b/src/jcode/core/log.ss
@@ -81,7 +81,8 @@
          (suffix (if (null? data) "" (string-append "  " (format-alist data)))))
     (with-mutex *log-mutex*
       (when (level-enabled? level (*log-level*))
-        (fprintf (current-error-port) "~a~a~n" line suffix))
+        (fprintf (current-error-port) "~a~a~n" line suffix)
+        (flush-output-port (current-error-port)))
       (when *trace-port*
         (fprintf *trace-port* "~a ~a~a~n" (trace-timestamp) line suffix)))))
 
diff --git a/src/jcode/core/session.ss b/src/jcode/core/session.ss
index 1e7cbfc..7593586 100644
--- a/src/jcode/core/session.ss
+++ b/src/jcode/core/session.ss
@@ -10,6 +10,7 @@
         session-replace-messages
         session-update-title
         session-delete
+        session-repair-orphan-tool-calls!
         session-id
         session-title
         session-created
@@ -212,6 +213,59 @@
                      (vector-ref row 2)))
              rows)))))
 
+(def (session-repair-orphan-tool-calls! session-id)
+  "Repair a history that ends with an assistant tool_calls message that
+   doesn't have matching tool result messages — happens after a turn
+   was cancelled mid-tool-execution. The OpenAI-style API requires every
+   tool_call_id to be answered, so we synthesize a '(cancelled)' tool
+   result for each unmatched id."
+  (with-db
+    (lambda (db)
+      (let ((rows (sqlite-query db
+                    "SELECT id, role, tool_calls, tool_call_id
+                     FROM messages WHERE session_id = ?
+                     ORDER BY id DESC LIMIT 16"
+                    session-id)))
+        (when (pair? rows)
+          (let* ((rev rows)  ;; rows are already DESC; treat as recent-first
+                 (answered  ;; tool_call_ids that already have a tool result
+                  (let loop ((rs rev) (acc '()))
+                    (cond
+                      ((null? rs) acc)
+                      ((equal? (vector-ref (car rs) 1) "tool")
+                       (loop (cdr rs) (cons (vector-ref (car rs) 3) acc)))
+                      (else acc))))  ;; stop scan at first non-tool message
+                 (last-asst
+                  ;; Find the most-recent assistant message in this scan window.
+                  (let loop ((rs rev))
+                    (cond
+                      ((null? rs) #f)
+                      ((equal? (vector-ref (car rs) 1) "assistant") (car rs))
+                      (else (loop (cdr rs)))))))
+            (when (and last-asst
+                       (let ((tcs-json (vector-ref last-asst 2)))
+                         (and tcs-json (not (equal? tcs-json "")))))
+              (let* ((parsed (try (string->json-object (vector-ref last-asst 2))
+                              (catch (_) '())))
+                     (ids (if (pair? parsed)
+                            (map (lambda (j) (hash-ref j "id" #f)) parsed)
+                            '()))
+                     (missing (filter (lambda (id)
+                                        (and id (not (member id answered))))
+                                      ids)))
+                (when (pair? missing)
+                  (let ((now (timestamp-now)))
+                    (for-each
+                      (lambda (id)
+                        (sqlite-eval db
+                          "INSERT INTO messages (session_id, role, content, tool_calls, tool_call_id, created_at)
+                           VALUES (?, 'tool', '(cancelled)', NULL, ?, ?)"
+                          session-id id now))
+                      missing)
+                    (sqlite-eval db
+                      "UPDATE sessions SET updated_at = ? WHERE id = ?"
+                      now session-id)))))))))))
+
 (def (session-delete session-id)
   (with-db
     (lambda (db)
diff --git a/src/jcode/provider/provider.ss b/src/jcode/provider/provider.ss
index 3b44029..7c811d7 100644
--- a/src/jcode/provider/provider.ss
+++ b/src/jcode/provider/provider.ss
@@ -10,7 +10,8 @@
         provider-list-pricing
         provider-name
         provider-model
-        provider-base-url)
+        provider-base-url
+        model-rejects-tools?)
 
 (import :std/text/json
         :std/net/request
diff --git a/src/jcode/ui/serve.ss b/src/jcode/ui/serve.ss
index 37548d8..4b969bf 100644
--- a/src/jcode/ui/serve.ss
+++ b/src/jcode/ui/serve.ss
@@ -10,15 +10,20 @@
 ;;;   {"type":"new_session","title":"..."}                start new session
 ;;;   {"type":"switch_session","id":"..."}                switch existing
 ;;;   {"type":"list_sessions"}                            enumerate sessions
+;;;   {"type":"cancel"}                                   abort in-flight turn
 ;;;   {"type":"ping"}                                     heartbeat
 ;;;
 ;;; ── Output events (jcode → client) ─────────────────────────────────────
 ;;;   {"type":"auth_ok"}                             TCP-only: auth accepted
 ;;;   {"type":"ready","session_id":"...","session_title":"..."}
+;;;   {"type":"history","role":"user|assistant","content":"..."}
+;;;   {"type":"history_tool","name":"...","args":"<json-string>"}
+;;;   {"type":"history_end"}                         end of history replay
 ;;;   {"type":"token","text":"..."}               streaming text delta
 ;;;   {"type":"tool_start","name":"...","args":{...}}
 ;;;   {"type":"tool_end","name":"..."}
 ;;;   {"type":"turn_end","session_id":"..."}
+;;;   {"type":"cancelled"}                           turn aborted by client
 ;;;   {"type":"error","message":"..."}
 ;;;   {"type":"session","id":"...","title":"...","created":"..."}
 ;;;   {"type":"sessions_end"}
@@ -39,6 +44,7 @@
         :jcode/core/config
         :jcode/core/models
         :jcode/core/session
+        :jcode/core/message
         :jcode/core/agent
         :jcode/tool/registry
         :jcode/mcp/client
@@ -60,13 +66,29 @@
 
 ;; ── event I/O ──────────────────────────────────────────────────────────
 
+;; Serializes writes to *serve-out* so the worker thread (streaming tokens
+;; during a turn) and the reader thread (handling control events) can't
+;; interleave half-lines on the wire. Uses the Gambit-compatible
+;; mutex-lock!/mutex-unlock! pair from :std/misc/thread because the make-mutex
+;; in scope wraps the Chez native mutex in a gerbil-mutex struct, so the
+;; Chez `with-mutex` macro can't operate on it.
+(def *out-mutex* (make-mutex))
+
+(def (with-out-lock thunk)
+  (mutex-lock! *out-mutex*)
+  (try
+    (thunk)
+    (finally (mutex-unlock! *out-mutex*))))
+
 (def (emit-event type pairs)
   (let ((ht (make-hash-table)))
     (hash-put! ht "type" type)
     (for-each (lambda (p) (hash-put! ht (car p) (cdr p))) pairs)
-    (display (json-object->string ht) (*serve-out*))
-    (newline (*serve-out*))
-    (flush-output-port (*serve-out*))))
+    (with-out-lock
+      (lambda ()
+        (display (json-object->string ht) (*serve-out*))
+        (newline (*serve-out*))
+        (flush-output-port (*serve-out*))))))
 
 (def (emit-error msg)
   (emit-event "error" `(("message" . ,msg))))
@@ -84,36 +106,19 @@
     (thunk)
     (finally (apply-mode! "build"))))
 
-;; ── callbacks wired into agent-run ─────────────────────────────────────
-
-(def (serve-stream-cb token)
-  (emit-event "token" `(("text" . ,token))))
-
-(def (serve-tool-cb phase name args)
-  (cond
-    ((eq? phase 'start)
-     (emit-event "tool_start"
-       `(("name" . ,name)
-         ("args" . ,args))))
-    ((eq? phase 'end)
-     (emit-event "tool_end" `(("name" . ,name))))))
-
-(def (serve-usage-cb usage)
-  (let ((tin 0) (tout 0) (cost 0.0))
-    (for-each
-      (lambda (pair)
-        (case (car pair)
-          ((tokens-in) (set! tin (cdr pair)))
-          ((tokens-out) (set! tout (cdr pair)))
-          ((cost) (set! cost (cdr pair)))))
-      usage)
-    (set! *session-tokens-in* (+ *session-tokens-in* tin))
-    (set! *session-tokens-out* (+ *session-tokens-out* tout))
-    (set! *session-cost* (+ *session-cost* cost))
-    (emit-event "usage"
-      `(("tokens_in" . ,*session-tokens-in*)
-        ("tokens_out" . ,*session-tokens-out*)
-        ("cost" . ,*session-cost*)))))
+;; ── cancellation ───────────────────────────────────────────────────────
+;;
+;; Cancel uses the same orphan-and-bump trick as the TUI: cancel doesn't
+;; wait for the worker to unblock. handle-cancel sets *serve-abort* (so
+;; the next stream/tool callback raises and unwinds the HTTP request)
+;; AND bumps *turn-gen* (so the worker's events get dropped at emit time
+;; — this matters when the model is in a non-streaming "thinking" phase
+;; with no callbacks firing). The UI gets `cancelled` + `turn_end`
+;; immediately and the orphaned worker dies silently whenever it
+;; eventually finishes.
+
+(def *serve-abort* (cons #f #f))
+(def *turn-gen* 0)
 
 ;; ── session state ──────────────────────────────────────────────────────
 
@@ -125,35 +130,190 @@
 (def (ensure-session!)
   (unless *current-session*
     (let ((s (session-create "Serve session")))
-      (set! *current-session* (session-id s))))
+      (set! *current-session* (session-id s))
+      (save-last-session-id! (session-id s))))
   *current-session*)
 
+;; ── persistent "last session" pointer ──────────────────────────────────
+;; A single text file in ~/.jcode names the most recent session id used by
+;; this server. On a fresh TCP connection we try to resume that session so
+;; the APK gets its history back even though it doesn't store anything
+;; locally itself.
+
+(def (last-session-path)
+  (path-join (or (getenv "HOME") ".") ".jcode" "last-session"))
+
+(def (save-last-session-id! sid)
+  (try
+    (begin
+      (ensure-jcode-dir!)
+      (write-file-string (last-session-path) sid))
+    (catch (_) (void))))
+
+(def (load-last-session-id)
+  (let ((p (last-session-path)))
+    (and (file-exists? p)
+         (try (string-trim (read-file-string p))
+           (catch (_) #f)))))
+
+;; ── history replay ─────────────────────────────────────────────────────
+;; After `ready`, walk the stored messages for the active session and
+;; emit one event per renderable item so the client can repaint its chat
+;; view. `history_end` marks completion.
+
+(def (emit-history sid)
+  (let ((msgs (try (session-get-messages sid) (catch (_) '()))))
+    (for-each emit-history-message msgs)
+    (emit-event "history_end" '())))
+
+(def (emit-history-message msg)
+  (let ((role (message-role msg))
+        (content (message-content msg))
+        (tcs (message-tool-calls msg)))
+    (cond
+      ((equal? role "user")
+       (when (and content (not (equal? content "")))
+         (emit-event "history"
+           `(("role" . "user") ("content" . ,content)))))
+      ((equal? role "assistant")
+       (when (and content (not (equal? content "")))
+         (emit-event "history"
+           `(("role" . "assistant") ("content" . ,content))))
+       (when (pair? tcs)
+         (for-each
+           (lambda (tc)
+             (let ((args (tool-call-arguments tc)))
+               (emit-event "history_tool"
+                 `(("name" . ,(tool-call-name tc))
+                   ("args" . ,(if (string? args) args
+                                  (json-object->string args)))))))
+           tcs))))))
+
+;; ── callbacks wired into agent-run (gen-aware) ────────────────────────
+
+(def (cb-aborted? gen)
+  (or (car *serve-abort*)
+      (not (= gen *turn-gen*))))
+
+(def (make-stream-cb gen)
+  (lambda (token)
+    (when (cb-aborted? gen)
+      (error 'stream-aborted "cancelled by client"))
+    (emit-event "token" `(("text" . ,token)))))
+
+(def (make-tool-cb gen)
+  (lambda (phase name args)
+    (when (and (eq? phase 'start) (cb-aborted? gen))
+      (error 'stream-aborted "cancelled by client"))
+    ;; Drop emissions from a stale generation (orphaned worker after cancel).
+    (when (= gen *turn-gen*)
+      (cond
+        ((eq? phase 'start)
+         (emit-event "tool_start"
+           `(("name" . ,name)
+             ("args" . ,args))))
+        ((eq? phase 'end)
+         (emit-event "tool_end" `(("name" . ,name))))))))
+
+(def (make-usage-cb gen)
+  (lambda (usage)
+    (when (= gen *turn-gen*)
+      (let ((tin 0) (tout 0) (cost 0.0))
+        (for-each
+          (lambda (pair)
+            (case (car pair)
+              ((tokens-in) (set! tin (cdr pair)))
+              ((tokens-out) (set! tout (cdr pair)))
+              ((cost) (set! cost (cdr pair)))))
+          usage)
+        (set! *session-tokens-in* (+ *session-tokens-in* tin))
+        (set! *session-tokens-out* (+ *session-tokens-out* tout))
+        (set! *session-cost* (+ *session-cost* cost))
+        (emit-event "usage"
+          `(("tokens_in" . ,*session-tokens-in*)
+            ("tokens_out" . ,*session-tokens-out*)
+            ("cost" . ,*session-cost*)))))))
+
 ;; ── event handlers ─────────────────────────────────────────────────────
 
+;; Tracks the worker thread for the in-flight turn (or #f when idle).
+;; A second "user" event arriving while a turn is still running is
+;; rejected rather than silently spawning a concurrent worker that
+;; would corrupt streaming output.
+(def *turn-worker* #f)
+
+(def (turn-busy?)
+  (and *turn-worker* (not (thread-done? *turn-worker*))))
+
+(def (run-turn-worker sid text mode gen)
+  (try
+    (with-mode mode
+      (lambda ()
+        (parameterize ((current-stream-cb (make-stream-cb gen))
+                       (current-tool-cb (make-tool-cb gen))
+                       (current-usage-cb (make-usage-cb gen)))
+          (agent-run sid text))))
+    (when (= gen *turn-gen*)
+      (emit-event "turn_end" `(("session_id" . ,sid))))
+    (catch (e)
+      (let ((msg (err->string e)))
+        ;; Cancelling between assistant-with-tool_calls and tool-result
+        ;; appends leaves the history in a state the API rejects on the
+        ;; next turn. Repair it now so the next turn isn't poisoned.
+        (try (session-repair-orphan-tool-calls! sid) (catch (_) (void)))
+        (cond
+          ;; Cancelled: handle-cancel already emitted cancelled+turn_end.
+          ;; Stay silent so the orphaned worker doesn't double-emit.
+          ((string-contains msg "stream-aborted") (void))
+          ;; Stale generation: same — we were orphaned, don't say anything.
+          ((not (= gen *turn-gen*)) (void))
+          (else
+           (log-error logger "turn-error" `((msg . ,msg)))
+           (emit-error msg)
+           (emit-event "turn_end" `(("session_id" . ,sid)))))))))
+
 (def (handle-user text mode)
-  (let ((sid (ensure-session!)))
-    (try
-      (with-mode mode
-        (lambda ()
-          (parameterize ((current-stream-cb serve-stream-cb)
-                         (current-tool-cb serve-tool-cb)
-                         (current-usage-cb serve-usage-cb))
-            (agent-run sid text))))
-      (emit-event "turn_end" `(("session_id" . ,sid)))
-      (catch (e)
-        (log-error logger "turn-error" `((msg . ,(err->string e))))
-        (emit-error (err->string e))
-        (emit-event "turn_end" `(("session_id" . ,sid)))))))
+  (cond
+    ((turn-busy?)
+     (emit-error "turn already in progress; send {\"type\":\"cancel\"} first"))
+    (else
+     (let ((sid (ensure-session!))
+           (gen (+ 1 *turn-gen*)))
+       (set! *turn-gen* gen)
+       (set-car! *serve-abort* #f)
+       (set! *turn-worker*
+         (spawn (lambda () (run-turn-worker sid text mode gen))))))))
+
+(def (handle-cancel)
+  (let ((sid (or *current-session* "")))
+    (cond
+      ((turn-busy?)
+       ;; Set abort + bump gen. The worker is orphaned: even if it's blocked
+       ;; in HTTP with no callbacks firing, we ack the client immediately
+       ;; and drop any future events from the orphaned generation. The
+       ;; worker eventually unblocks (next streaming chunk OR HTTP timeout)
+       ;; and exits silently because its callbacks no longer match *turn-gen*.
+       (set-car! *serve-abort* #t)
+       (set! *turn-gen* (+ 1 *turn-gen*))
+       (set! *turn-worker* #f)
+       (log-info logger "cancel" '((msg . "abort flag set, worker orphaned")))
+       (emit-event "cancelled" '())
+       (emit-event "turn_end" `(("session_id" . ,sid))))
+      (else
+       ;; No turn in flight — ack quietly so the client UI can reset state.
+       (emit-event "cancelled" '())))))
 
 (def (handle-new-session title)
   (let ((s (session-create (or title "New session"))))
     (set! *current-session* (session-id s))
+    (save-last-session-id! (session-id s))
     (set! *session-tokens-in* 0)
     (set! *session-tokens-out* 0)
     (set! *session-cost* 0.0)
     (emit-event "ready"
       `(("session_id" . ,(session-id s))
-        ("session_title" . ,(session-title s))))))
+        ("session_title" . ,(session-title s))))
+    (emit-history (session-id s))))
 
 (def (handle-switch-session id)
   (let ((s (session-load id)))
@@ -162,9 +322,11 @@
        (emit-error (format "session not found: ~a" id)))
       (else
        (set! *current-session* (session-id s))
+       (save-last-session-id! (session-id s))
        (emit-event "ready"
          `(("session_id" . ,(session-id s))
-           ("session_title" . ,(session-title s))))))))
+           ("session_title" . ,(session-title s))))
+       (emit-history (session-id s))))))
 
 (def (handle-list-sessions)
   (for-each
@@ -266,6 +428,8 @@
            (emit-error "set_model missing 'name'"))))
       ((equal? type "get_status")
        (handle-get-status))
+      ((equal? type "cancel")
+       (handle-cancel))
       ((equal? type "ping")
        (emit-event "pong" '()))
       (else
@@ -274,12 +438,15 @@
 ;; ── JSONL event loop (shared between stdio and TCP) ───────────────────
 
 (def (serve-loop)
-  (let ((sid (ensure-session!)))
+  (let* ((sid (ensure-session!))
+         (s (session-load sid))
+         (title (if s (session-title s) "Serve session")))
     (emit-event "ready"
       `(("session_id" . ,sid)
-        ("session_title" . "Serve session")))
+        ("session_title" . ,title)))
     (handle-get-config)
-    (handle-get-status))
+    (handle-get-status)
+    (emit-history sid))
   (let loop ()
     (let ((line (get-line (*serve-in*))))
       (cond
@@ -482,8 +649,13 @@
         (try
           (parameterize ((*serve-in* in)
                          (*serve-out* out))
-            ;; Reset session for new client connection
-            (set! *current-session* #f)
+            ;; New client connection: try to resume the last active session
+            ;; (preserved across server restarts via ~/.jcode/last-session).
+            ;; If the file is missing or points to a deleted session, fall
+            ;; through and ensure-session! will create a fresh one.
+            (let ((last (load-last-session-id)))
+              (set! *current-session*
+                (and last (session-load last) last)))
             (set! *session-tokens-in* 0)
             (set! *session-tokens-out* 0)
             (set! *session-cost* 0.0)
@@ -581,7 +753,15 @@
                  (name . ,name)))
              (relay-host-loop relay-addr relay-port name relay-token server-token))))
         (port-opt
-         (let ((bind-addr (if bind-opt (cdr bind-opt) "127.0.0.1")))
+         ;; TCP mode — auto-redirect stderr to ./jcode.log so the user
+         ;; doesn't have to remember `2>> jcode.log` on every restart.
+         (let* ((log-path "jcode.log")
+                (log-port (try (open-output-file log-path '(append))
+                           (catch (_) (open-output-file log-path))))
+                (bind-addr (if bind-opt (cdr bind-opt) "127.0.0.1")))
+           (fprintf (current-error-port) "[INFO] logs → ~a~n" log-path)
+           (flush-output-port (current-error-port))
+           (current-error-port log-port)
            (log-info logger "serve-start"
              `((mode . "tcp") (bind . ,bind-addr) (port . ,(cdr port-opt))))
            (tcp-serve-loop bind-addr (cdr port-opt))))