@swarmclawai/swarmclaw 1.5.66 → 1.5.67

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -399,6 +399,14 @@ Operational docs: https://swarmclaw.ai/docs/observability
399
399
 
400
400
  ## Releases
401
401
 
402
+ ### v1.5.67 Highlights
403
+
404
+ Three chatroom-focused fixes from a community contribution by [@borislavnnikolov](https://github.com/borislavnnikolov). Thanks Borislav!
405
+
406
+ - **Inspect a chatroom member mid-turn.** Clicking a member avatar in a chatroom while that agent is busy now opens a bottom sheet with the agent's synthetic chatroom session: recent messages, execution-log entries, and counts. Previously the click jumped to the agent detail page, which lost the chatroom context. The synthetic session-id convention (`chatroom-<roomId>-<agentId>`) is now centralized in `src/lib/chatroom-sessions.ts` and shared between the UI and `chatroom-helpers.ts` so the two halves can never drift.
407
+ - **Continue a specific session, not just the agent's main thread.** The store now exposes an `activeSessionIdOverride` on the session slice. Selecting a chat from the Chat List or a specific session row from the Agent Inspector sets the override, so the chat surface opens that exact session instead of the agent's primary thread session. The override clears automatically when the agent changes or the session is removed, with regression tests in `session-slice.test.ts` covering the override-preferred, override-stale, and fallback cases.
408
+ - **Caret stays aligned with mention highlights in the chatroom composer.** The mention-highlight `<span>` had `px-0.5` padding that pushed the mirrored caret out of position at line ends. Padding is removed and the soft-accent background lightened slightly so the highlight still reads without nudging the layout.
409
+
402
410
  ### v1.5.66 Highlights
403
411
 
404
412
  Fixes a runaway-token-burn bug in the orchestrator-wake and heartbeat loops. The root cause was hidden in the success/failure classification: a session run can resolve its promise successfully while still carrying an `error` on the result (e.g. a provider 429 swallowed into persisted output), and the wake trackers only incremented their failure counters on a rejected promise. So the backoff never engaged, the auto-disable-after-N-failures gate never tripped, and the wake kept firing at its configured interval indefinitely — every firing spending tokens on a full prompt against a provider that was already cooling down.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "1.5.66",
3
+ "version": "1.5.67",
4
4
  "description": "Build and run autonomous AI agents with OpenClaw, Hermes, multiple model providers, orchestration, delegation, memory, skills, schedules, and chat connectors.",
5
5
  "main": "electron-dist/main.js",
6
6
  "license": "MIT",
@@ -903,6 +903,8 @@ function SessionsSection({ agent }: { agent: Agent }) {
903
903
  const connectors = useAppStore((s) => s.connectors)
904
904
  const agents = useAppStore((s) => s.agents)
905
905
  const setCurrentAgent = useAppStore((s) => s.setCurrentAgent)
906
+ const setActiveSessionIdOverride = useAppStore((s) => s.setActiveSessionIdOverride)
907
+ const setInspectorOpen = useAppStore((s) => s.setInspectorOpen)
906
908
 
907
909
  const agentSessions = useMemo(() => {
908
910
  return Object.values(sessions).filter((s) => s.agentId === agent.id)
@@ -922,7 +924,15 @@ function SessionsSection({ agent }: { agent: Agent }) {
922
924
  <button
923
925
  key={s.id}
924
926
  type="button"
925
- onClick={() => void setCurrentAgent(agent.id)}
927
+ onClick={() => {
928
+ void setCurrentAgent(agent.id).then(() => {
929
+ setActiveSessionIdOverride(s.id)
930
+ setInspectorOpen(false)
931
+ if (typeof window !== 'undefined') {
932
+ window.dispatchEvent(new CustomEvent('swarmclaw:scroll-bottom'))
933
+ }
934
+ }).catch(() => {})
935
+ }}
926
936
  className="flex items-center gap-2 w-full py-1.5 px-2 rounded-[8px] bg-transparent border-none cursor-pointer hover:bg-white/[0.04] transition-colors text-left"
927
937
  >
928
938
  {connector ? (
@@ -26,6 +26,7 @@ export function ChatList({ inSidebar, onSelect }: Props) {
26
26
  const currentUser = useAppStore((s) => s.currentUser)
27
27
  const currentSessionId = useAppStore(selectActiveSessionId)
28
28
  const setCurrentAgent = useAppStore((s) => s.setCurrentAgent)
29
+ const setActiveSessionIdOverride = useAppStore((s) => s.setActiveSessionIdOverride)
29
30
  const loadConnectors = useAppStore((s) => s.loadConnectors)
30
31
  const setAgentSheetOpen = useAppStore((s) => s.setAgentSheetOpen)
31
32
  const clearSessions = useAppStore((s) => s.clearSessions)
@@ -117,7 +118,10 @@ export function ChatList({ inSidebar, onSelect }: Props) {
117
118
 
118
119
  const handleSelect = async (id: string) => {
119
120
  const agentId = sessions[id]?.agentId
120
- if (agentId) void setCurrentAgent(agentId)
121
+ if (agentId) {
122
+ await setCurrentAgent(agentId)
123
+ setActiveSessionIdOverride(id)
124
+ }
121
125
  markChatRead(id)
122
126
  if (typeof window !== 'undefined') {
123
127
  window.dispatchEvent(new CustomEvent('swarmclaw:scroll-bottom'))
@@ -176,7 +176,7 @@ export function ChatroomInput({ agents, onSend, disabled, onBreakoutRequest }: P
176
176
  parts.push(text.slice(lastIndex, match.index))
177
177
  }
178
178
  parts.push(
179
- <span key={match.index} className="bg-accent-soft/50 text-accent-bright rounded px-0.5">
179
+ <span key={match.index} className="bg-accent-soft/45 text-accent-bright rounded">
180
180
  {match[0]}
181
181
  </span>
182
182
  )
@@ -9,6 +9,7 @@ import { useNavigate } from '@/lib/app/navigation'
9
9
  import { useNow } from '@/hooks/use-now'
10
10
  import { useWs } from '@/hooks/use-ws'
11
11
  import { api } from '@/lib/app/api-client'
12
+ import { resolveChatroomSyntheticSessionId } from '@/lib/chatroom-sessions'
12
13
  import { ChatroomMessageBubble } from './chatroom-message'
13
14
  import { ChatroomInput } from './chatroom-input'
14
15
  import { ChatroomTypingBar } from './chatroom-typing-bar'
@@ -20,7 +21,7 @@ import {
20
21
  StructuredSessionLauncher,
21
22
  type StructuredSessionLaunchContext,
22
23
  } from '@/components/protocols/structured-session-launcher'
23
- import type { Chatroom, ChatroomMessage, ChatroomMember, Agent, ProtocolRun } from '@/types'
24
+ import type { Chatroom, ChatroomMessage, ChatroomMember, Agent, ProtocolRun, Session, Message } from '@/types'
24
25
 
25
26
  function getRoleBadge(role: string) {
26
27
  if (role === 'admin') return { label: 'Admin', className: 'bg-purple-500/20 text-purple-400 border-purple-500/30' }
@@ -45,6 +46,13 @@ function isAgentMuted(chatroom: Chatroom, agentId: string, now: number | null):
45
46
  }
46
47
 
47
48
  type MomentType = { kind: 'heartbeat' } | { kind: 'tool'; name: string; input: string }
49
+ type SessionExecLogEntry = {
50
+ id: string
51
+ category: string
52
+ summary: string
53
+ detail: Record<string, unknown> | null
54
+ ts: number
55
+ }
48
56
 
49
57
  function useAgentHeartbeat(agentId: string, onPulse: (id: string) => void) {
50
58
  const topic = agentId ? `heartbeat:agent:${agentId}` : ''
@@ -120,6 +128,13 @@ export function ChatroomView() {
120
128
  const [injectError, setInjectError] = useState<string | null>(null)
121
129
  const [linkedRun, setLinkedRun] = useState<ProtocolRun | null>(null)
122
130
  const [activeParentRun, setActiveParentRun] = useState<ProtocolRun | null>(null)
131
+ const [inspectSessionOpen, setInspectSessionOpen] = useState(false)
132
+ const [inspectedAgent, setInspectedAgent] = useState<Agent | null>(null)
133
+ const [inspectedSessionId, setInspectedSessionId] = useState<string | null>(null)
134
+ const [inspectedMessages, setInspectedMessages] = useState<Message[]>([])
135
+ const [inspectedExecLogs, setInspectedExecLogs] = useState<SessionExecLogEntry[]>([])
136
+ const [inspectLoading, setInspectLoading] = useState(false)
137
+ const [inspectError, setInspectError] = useState<string | null>(null)
123
138
 
124
139
  const handleHeartbeatPulse = useCallback((agentId: string) => {
125
140
  setAgentMoments((prev) => ({ ...prev, [agentId]: { kind: 'heartbeat' } }))
@@ -227,6 +242,8 @@ export function ChatroomView() {
227
242
 
228
243
  useEffect(() => {
229
244
  setDetailsOpen(false)
245
+ setInspectSessionOpen(false)
246
+ setInspectError(null)
230
247
  }, [chatroomId])
231
248
 
232
249
  const refreshLinkedRun = useCallback(() => {
@@ -330,6 +347,29 @@ export function ChatroomView() {
330
347
  }
331
348
  }
332
349
 
350
+ const handleInspectAgentSession = async (agent: Agent) => {
351
+ if (!chatroom) return
352
+ const sessionId = resolveChatroomSyntheticSessionId(chatroom.id, agent.id)
353
+ setInspectSessionOpen(true)
354
+ setInspectedAgent(agent)
355
+ setInspectedSessionId(sessionId)
356
+ setInspectLoading(true)
357
+ setInspectError(null)
358
+
359
+ try {
360
+ const session = await api<Session>('GET', `/chats/${encodeURIComponent(sessionId)}`)
361
+ setInspectedMessages(Array.isArray(session?.messages) ? session.messages : [])
362
+ const logs = await api<SessionExecLogEntry[]>('GET', `/chats/${encodeURIComponent(sessionId)}/execution-log?limit=200`)
363
+ setInspectedExecLogs(Array.isArray(logs) ? logs : [])
364
+ } catch (error) {
365
+ setInspectedMessages([])
366
+ setInspectedExecLogs([])
367
+ setInspectError(error instanceof Error ? error.message : 'Unable to load this agent session yet.')
368
+ } finally {
369
+ setInspectLoading(false)
370
+ }
371
+ }
372
+
333
373
  return (
334
374
  <div className="flex-1 flex min-h-0 min-w-0">
335
375
  <div className="min-w-0 flex-1 flex flex-col h-full">
@@ -379,7 +419,13 @@ export function ChatroomView() {
379
419
  <Tooltip key={agent.id}>
380
420
  <TooltipTrigger asChild>
381
421
  <button
382
- onClick={() => navigateToAgent(agent.id)}
422
+ onClick={() => {
423
+ if (streamingAgents.has(agent.id)) {
424
+ void handleInspectAgentSession(agent)
425
+ return
426
+ }
427
+ navigateToAgent(agent.id)
428
+ }}
383
429
  className={`relative transition-all duration-200 hover:scale-110 hover:z-10 hover:-translate-y-0.5 cursor-pointer bg-transparent border-none p-0 ${muted ? 'opacity-40' : ''}`}
384
430
  >
385
431
  <AgentAvatar seed={agent.avatarSeed} avatarUrl={agent.avatarUrl} name={agent.name} size={22} status={streamingAgents.has(agent.id) ? 'busy' : 'online'} />
@@ -393,6 +439,7 @@ export function ChatroomView() {
393
439
  <TooltipContent side="bottom" sideOffset={6}>
394
440
  <div className="flex items-center gap-1.5">
395
441
  <span>{agent.name}</span>
442
+ {streamingAgents.has(agent.id) && <span className="text-[9px] text-sky-300">Click to inspect</span>}
396
443
  {badge && <span className={`text-[9px] font-600 px-1 py-0.5 rounded border ${badge.className}`}>{badge.label}</span>}
397
444
  {muted && <span className="text-[9px] text-red-400">Muted</span>}
398
445
  </div>
@@ -635,6 +682,7 @@ export function ChatroomView() {
635
682
  now={now}
636
683
  onFocusMessage={focusMessage}
637
684
  onNavigateToAgent={navigateToAgent}
685
+ onInspectAgentSession={handleInspectAgentSession}
638
686
  />
639
687
  </aside>
640
688
 
@@ -652,9 +700,95 @@ export function ChatroomView() {
652
700
  setTimeout(() => focusMessage(messageId), 50)
653
701
  }}
654
702
  onNavigateToAgent={navigateToAgent}
703
+ onInspectAgentSession={handleInspectAgentSession}
655
704
  compact
656
705
  />
657
706
  </BottomSheet>
707
+ <BottomSheet
708
+ open={inspectSessionOpen}
709
+ onClose={() => {
710
+ setInspectSessionOpen(false)
711
+ setInspectError(null)
712
+ }}
713
+ title={inspectedAgent ? `${inspectedAgent.name} session` : 'Agent session'}
714
+ description={inspectedSessionId ? `Inspecting ${inspectedSessionId}` : 'Inspecting active chatroom member session'}
715
+ >
716
+ {inspectLoading ? (
717
+ <div className="rounded-[12px] border border-white/[0.06] bg-white/[0.02] px-4 py-6 text-[13px] text-text-3">
718
+ Loading session activity…
719
+ </div>
720
+ ) : inspectError ? (
721
+ <div className="rounded-[12px] border border-red-500/20 bg-red-500/10 px-4 py-3 text-[13px] text-red-200">
722
+ {inspectError}
723
+ </div>
724
+ ) : (
725
+ <div className="space-y-4">
726
+ <div className="grid grid-cols-3 gap-2">
727
+ <div className="rounded-[10px] border border-white/[0.06] bg-white/[0.02] px-3 py-2.5">
728
+ <div className="text-[16px] font-display font-700 text-text">{inspectedMessages.length}</div>
729
+ <div className="mt-0.5 text-[10px] uppercase tracking-[0.08em] text-text-3/50">Messages</div>
730
+ </div>
731
+ <div className="rounded-[10px] border border-white/[0.06] bg-white/[0.02] px-3 py-2.5">
732
+ <div className="text-[16px] font-display font-700 text-sky-300">{inspectedExecLogs.length}</div>
733
+ <div className="mt-0.5 text-[10px] uppercase tracking-[0.08em] text-text-3/50">Events</div>
734
+ </div>
735
+ <div className="rounded-[10px] border border-white/[0.06] bg-white/[0.02] px-3 py-2.5">
736
+ <div className="text-[12px] font-mono text-text-2 truncate" title={inspectedSessionId || undefined}>
737
+ {inspectedSessionId || '—'}
738
+ </div>
739
+ <div className="mt-0.5 text-[10px] uppercase tracking-[0.08em] text-text-3/50">Session ID</div>
740
+ </div>
741
+ </div>
742
+
743
+ <section>
744
+ <h4 className="mb-2 text-[12px] font-700 uppercase tracking-[0.08em] text-text-3/60">Recent Messages</h4>
745
+ <div className="max-h-[220px] space-y-2 overflow-y-auto rounded-[12px] border border-white/[0.06] bg-white/[0.02] p-3">
746
+ {inspectedMessages.length === 0 ? (
747
+ <p className="text-[12px] text-text-3">No messages yet for this session.</p>
748
+ ) : (
749
+ inspectedMessages.slice(-12).map((message, index) => (
750
+ <div key={`${message.time}-${message.role}-${index}`} className="rounded-[10px] border border-white/[0.05] bg-black/20 px-3 py-2">
751
+ <div className="flex items-center gap-2 text-[10px] uppercase tracking-[0.08em] text-text-3/60">
752
+ <span>{message.role}</span>
753
+ <span>·</span>
754
+ <span>{new Date(message.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}</span>
755
+ </div>
756
+ <p className="mt-1 text-[12px] leading-[1.5] text-text-2 whitespace-pre-wrap break-words">
757
+ {message.text || '(empty)'}
758
+ </p>
759
+ </div>
760
+ ))
761
+ )}
762
+ </div>
763
+ </section>
764
+
765
+ <section>
766
+ <h4 className="mb-2 text-[12px] font-700 uppercase tracking-[0.08em] text-text-3/60">Execution Log</h4>
767
+ <div className="max-h-[220px] space-y-2 overflow-y-auto rounded-[12px] border border-white/[0.06] bg-white/[0.02] p-3">
768
+ {inspectedExecLogs.length === 0 ? (
769
+ <p className="text-[12px] text-text-3">No execution log entries yet.</p>
770
+ ) : (
771
+ inspectedExecLogs
772
+ .slice()
773
+ .sort((a, b) => b.ts - a.ts)
774
+ .map((entry) => (
775
+ <div key={entry.id} className="rounded-[10px] border border-white/[0.05] bg-black/20 px-3 py-2">
776
+ <div className="flex items-center gap-2 text-[10px] uppercase tracking-[0.08em] text-text-3/60">
777
+ <span>{entry.category}</span>
778
+ <span>·</span>
779
+ <span>{new Date(entry.ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}</span>
780
+ </div>
781
+ <p className="mt-1 text-[12px] leading-[1.5] text-text-2 whitespace-pre-wrap break-words">
782
+ {entry.summary}
783
+ </p>
784
+ </div>
785
+ ))
786
+ )}
787
+ </div>
788
+ </section>
789
+ </div>
790
+ )}
791
+ </BottomSheet>
658
792
  <StructuredSessionLauncher
659
793
  open={structuredSessionOpen}
660
794
  onClose={() => {
@@ -721,6 +855,7 @@ function RoomDetailsPanel({
721
855
  now,
722
856
  onFocusMessage,
723
857
  onNavigateToAgent,
858
+ onInspectAgentSession,
724
859
  compact = false,
725
860
  }: {
726
861
  chatroom: Chatroom
@@ -732,6 +867,7 @@ function RoomDetailsPanel({
732
867
  now: number | null
733
868
  onFocusMessage: (messageId: string) => void
734
869
  onNavigateToAgent: (agentId: string) => void
870
+ onInspectAgentSession: (agent: Agent) => Promise<void>
735
871
  compact?: boolean
736
872
  }) {
737
873
  return (
@@ -771,7 +907,13 @@ function RoomDetailsPanel({
771
907
  return (
772
908
  <button
773
909
  key={agent.id}
774
- onClick={() => onNavigateToAgent(agent.id)}
910
+ onClick={() => {
911
+ if (streamingAgents.has(agent.id)) {
912
+ void onInspectAgentSession(agent)
913
+ return
914
+ }
915
+ onNavigateToAgent(agent.id)
916
+ }}
775
917
  className="w-full rounded-[12px] border border-white/[0.06] bg-white/[0.02] px-3 py-2.5 text-left hover:bg-white/[0.05] transition-all cursor-pointer"
776
918
  style={{ fontFamily: 'inherit' }}
777
919
  >
@@ -0,0 +1,4 @@
1
+ export function resolveChatroomSyntheticSessionId(chatroomId: string, agentId: string): string {
2
+ return `chatroom-${chatroomId}-${agentId}`
3
+ }
4
+
@@ -10,10 +10,12 @@ import {
10
10
  buildHistoryForAgent,
11
11
  buildSyntheticSession,
12
12
  resolveChatroomWorkspaceDir,
13
+ resolveSyntheticSessionId,
13
14
  resolveAgentApiEndpoint,
14
15
  resolveReplyTargetAgentId,
15
16
  buildAgentSystemPromptForChatroom,
16
17
  } from '@/lib/server/chatrooms/chatroom-helpers'
18
+ import { resolveChatroomSyntheticSessionId } from '@/lib/chatroom-sessions'
17
19
 
18
20
  function makeAgents(): Record<string, Agent> {
19
21
  const now = Date.now()
@@ -234,6 +236,11 @@ describe('chatroom-helpers', () => {
234
236
  assert.match(cwd, /chatrooms[\/\\]room-safe$/)
235
237
  })
236
238
 
239
+ it('uses a stable synthetic session id convention shared with the UI', () => {
240
+ assert.equal(resolveChatroomSyntheticSessionId('room-1', 'agent-1'), 'chatroom-room-1-agent-1')
241
+ assert.equal(resolveSyntheticSessionId('room-1', 'agent-1'), 'chatroom-room-1-agent-1')
242
+ })
243
+
237
244
  it('matches multi-word agent name over shorter prefix', () => {
238
245
  const agents = makeMultiWordAgents()
239
246
  const memberIds = ['hal2k', 'hal2k-openai', 'code-monkey']
@@ -13,6 +13,7 @@ import { buildRuntimeSkillPromptBlocks, resolveRuntimeSkills } from '@/lib/serve
13
13
  import { loadSkills } from '@/lib/server/skills/skill-repository'
14
14
  import { loadSession, patchSession, saveSession } from '@/lib/server/sessions/session-repository'
15
15
  import { appendMessage } from '@/lib/server/messages/message-repository'
16
+ import { resolveChatroomSyntheticSessionId } from '@/lib/chatroom-sessions'
16
17
  import type { Chatroom, ChatroomMember, Agent, Session, Message, ChatroomMessage } from '@/types'
17
18
  import { getEnabledCapabilityIds, getEnabledToolIds } from '@/lib/capability-selection'
18
19
 
@@ -272,7 +273,7 @@ export function resolveChatroomWorkspaceDir(chatroomId: string): string {
272
273
  }
273
274
 
274
275
  export function resolveSyntheticSessionId(chatroomId: string, agentId: string): string {
275
- return `chatroom-${chatroomId}-${agentId}`
276
+ return resolveChatroomSyntheticSessionId(chatroomId, agentId)
276
277
  }
277
278
 
278
279
  function buildEmptyDelegateResumeIds(): NonNullable<Session['delegateResumeIds']> {
@@ -27,14 +27,15 @@ export const createAgentSlice: StateCreator<AppState, [], [], AgentSlice> = (set
27
27
  currentAgentId: null,
28
28
  setCurrentAgent: async (id) => {
29
29
  if (!id) {
30
- set({ currentAgentId: null })
30
+ set({ currentAgentId: null, activeSessionIdOverride: null })
31
31
  safeStorageRemove('sc_agent')
32
32
  return
33
33
  }
34
34
  if (get().currentAgentId === id && get().agents[id]?.threadSessionId) {
35
+ set({ activeSessionIdOverride: null })
35
36
  return
36
37
  }
37
- set({ currentAgentId: id })
38
+ set({ currentAgentId: id, activeSessionIdOverride: null })
38
39
  safeStorageSet('sc_agent', id)
39
40
 
40
41
  await agentThreadDedup.dedup(id, async () => {
@@ -0,0 +1,52 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import type { Agent, Session } from '../../types'
4
+ import type { AppState } from '../use-app-store'
5
+ import { selectActiveSessionId } from './session-slice'
6
+
7
+ function makeState(overrides: Partial<AppState>): AppState {
8
+ return {
9
+ currentAgentId: null,
10
+ agents: {},
11
+ sessions: {},
12
+ activeSessionIdOverride: null,
13
+ ...overrides,
14
+ } as AppState
15
+ }
16
+
17
+ function makeAgent(id: string, threadSessionId: string): Agent {
18
+ return { id, threadSessionId } as unknown as Agent
19
+ }
20
+
21
+ function makeSession(id: string): Session {
22
+ return { id } as unknown as Session
23
+ }
24
+
25
+ test('selectActiveSessionId prefers override when present', () => {
26
+ const state = makeState({
27
+ currentAgentId: 'agent-1',
28
+ agents: { 'agent-1': makeAgent('agent-1', 'thread-1') },
29
+ sessions: { 'thread-1': makeSession('thread-1'), 'task-1': makeSession('task-1') },
30
+ activeSessionIdOverride: 'task-1',
31
+ })
32
+ assert.equal(selectActiveSessionId(state), 'task-1')
33
+ })
34
+
35
+ test('selectActiveSessionId falls back to agent thread session', () => {
36
+ const state = makeState({
37
+ currentAgentId: 'agent-1',
38
+ agents: { 'agent-1': makeAgent('agent-1', 'thread-1') },
39
+ sessions: { 'thread-1': makeSession('thread-1') },
40
+ })
41
+ assert.equal(selectActiveSessionId(state), 'thread-1')
42
+ })
43
+
44
+ test('selectActiveSessionId ignores stale override ids', () => {
45
+ const state = makeState({
46
+ currentAgentId: 'agent-1',
47
+ agents: { 'agent-1': makeAgent('agent-1', 'thread-1') },
48
+ sessions: { 'thread-1': makeSession('thread-1') },
49
+ activeSessionIdOverride: 'missing-session',
50
+ })
51
+ assert.equal(selectActiveSessionId(state), 'thread-1')
52
+ })
@@ -10,6 +10,9 @@ const sessionRefreshDedup = createInflightDeduplicator('sessionSlice_inflightRef
10
10
 
11
11
  /** Derive the active session ID from the current agent — no stored `currentSessionId`. */
12
12
  export function selectActiveSessionId(s: AppState): string | null {
13
+ if (s.activeSessionIdOverride && s.sessions[s.activeSessionIdOverride]) {
14
+ return s.activeSessionIdOverride
15
+ }
13
16
  if (!s.currentAgentId) return null
14
17
  const agent = s.agents[s.currentAgentId]
15
18
  return agent?.threadSessionId ?? null
@@ -17,6 +20,8 @@ export function selectActiveSessionId(s: AppState): string | null {
17
20
 
18
21
  export interface SessionSlice {
19
22
  sessions: Sessions
23
+ activeSessionIdOverride: string | null
24
+ setActiveSessionIdOverride: (id: string | null) => void
20
25
  loadSessions: () => Promise<void>
21
26
  refreshSession: (id: string) => Promise<void>
22
27
  removeSession: (id: string) => void
@@ -27,6 +32,8 @@ export interface SessionSlice {
27
32
 
28
33
  export const createSessionSlice: StateCreator<AppState, [], [], SessionSlice> = (set, get) => ({
29
34
  sessions: {},
35
+ activeSessionIdOverride: null,
36
+ setActiveSessionIdOverride: (id) => set({ activeSessionIdOverride: id }),
30
37
  loadSessions: createLoader<AppState>(set, 'sessions', () => fetchChats()),
31
38
  refreshSession: async (id) => {
32
39
  if (!id) return
@@ -51,9 +58,10 @@ export const createSessionSlice: StateCreator<AppState, [], [], SessionSlice> =
51
58
  invalidateFingerprint('sessions')
52
59
  const activeSessionId = selectActiveSessionId(get())
53
60
  if (activeSessionId === id) {
54
- set({ sessions, currentAgentId: null })
61
+ set({ sessions, currentAgentId: null, activeSessionIdOverride: null })
55
62
  } else {
56
- set({ sessions })
63
+ const overrideId = get().activeSessionIdOverride
64
+ set({ sessions, activeSessionIdOverride: overrideId === id ? null : overrideId })
57
65
  }
58
66
  },
59
67
  clearSessions: async (ids) => {
@@ -64,9 +72,13 @@ export const createSessionSlice: StateCreator<AppState, [], [], SessionSlice> =
64
72
  invalidateFingerprint('sessions')
65
73
  const activeSessionId = selectActiveSessionId(get())
66
74
  if (activeSessionId && ids.includes(activeSessionId)) {
67
- set({ sessions, currentAgentId: null })
75
+ set({ sessions, currentAgentId: null, activeSessionIdOverride: null })
68
76
  } else {
69
- set({ sessions })
77
+ const overrideId = get().activeSessionIdOverride
78
+ set({
79
+ sessions,
80
+ activeSessionIdOverride: overrideId && ids.includes(overrideId) ? null : overrideId,
81
+ })
70
82
  }
71
83
  },
72
84
  togglePinSession: async (id) => {