Skip to content

[Bug] Tab switching interrupts active streaming/agent in other tabs #437

@zhangboy03

Description

@zhangboy03

Environment

  • OS: macOS (Darwin 25.3.0)
  • Claudian: v1.3.72
  • Claude Agent SDK: ^0.2.76
  • Model: Claude Opus 4.6 (1M context)

Description

When switching between Claudian tabs while an agent is actively streaming/working in one tab, the active conversation gets interrupted. The agent stops mid-task, and when switching back, the user must manually send a message (e.g., "继续") to prompt it to continue — at which point the agent has lost its working context and has to re-orient from scratch.

Steps to Reproduce

  1. Open Claudian in Obsidian
  2. In Tab A, start a complex task (e.g., a codebase seminar that spawns subagents)
  3. While Tab A's agent is actively working (streaming, running tool calls, etc.), switch to Tab B
  4. Switch back to Tab A

Expected Behavior

Each tab's conversation should run independently. Switching to Tab B should not affect Tab A's active agent/streaming.

Actual Behavior

  • Tab A's agent is interrupted upon switching away
  • The UI shows (background task completed) — the auto-turn callback fires with tool activity but no text (renderAutoTriggeredTurn at Tab.ts:1224)
  • The agent does not resume on its own — the user must manually send a message to prompt it to continue
  • After receiving the manual prompt, the agent says "Let me orient myself and find where we left off" — it has lost its working context

Code Analysis

I traced through the TypeScript source (src/) to identify the root cause. Here's what I found and what I ruled out.

What I Ruled Out: switchToTab → setSessionId → closePersistentQuery

My initial hypothesis was that switchToTab() (TabManager.ts:148) calls tab.service.setSessionId() (line 192), which triggers closePersistentQuery('session switch') (ClaudianService.ts:1481). However, after deeper investigation, this should only affect the target tab's own service, because:

  • Each tab creates its own ClaudianService instance (new ClaudianService() at Tab.ts:252)
  • Each service spawns its own CLI process via agentQuery()createCustomSpawnFunction()child_process.spawn() (customSpawn.ts:33)
  • Each service has its own queryAbortController, persistentQuery, and messageChannel
  • switchToTab() only calls setSessionId() on the target tab's service, never the source tab's

So Tab B's session sync should be completely isolated from Tab A's active streaming.

The (background task completed) Symptom

This message comes from renderAutoTriggeredTurn() (Tab.ts:1203-1238). It fires when:

  1. A turnComplete message arrives in the response consumer loop (ClaudianService.ts:727)
  2. No handler is registered (handler is null at line 656)
  3. Buffered chunks have tool activity but no text content (Tab.ts:1222-1224)

This means Tab A's handler was already unregistered when the SDK delivered the turn completion. The handler's onDone() was called prematurely, causing state.done = true (line 1018), which broke the while (!state.done) yield loop (line 1057). Subsequent chunks from the CLI were routed to the auto-turn buffer instead.

Possible Root Causes (Not Yet Confirmed)

Since the code-level isolation appears correct, the actual root cause may be in a layer I couldn't fully inspect:

1. Claude Agent SDK internal shared state
The agentQuery() function from @anthropic-ai/claude-agent-sdk may use shared resources (session files, lock files, IPC channels) between multiple Query instances. When Tab B's service calls persistentQuery.interrupt(), it may affect Tab A's process through a shared session directory or similar mechanism.

2. Electron renderer throttling
When the Claudian view becomes non-foreground (switching Obsidian leaves, not Claudian tabs), Electron/Chromium may throttle the renderer process. If the response consumer loop's for await (line 564) depends on timely event loop processing, throttling could cause the stdio pipe to stall or the CLI process to timeout waiting for input.

3. Race condition in handler lifecycle
If isTurnCompleteMessage() fires at just the right moment during a tab switch, the handler could be unregistered before all chunks are delivered. The handler lifecycle assumes single-turn processing (line 646-648: "Only one handler exists at a time because MessageChannel enforces single-turn processing"), but subagent completion may create edge cases.

4. AbortSignal propagation
customSpawn.ts:44 registers signal.addEventListener('abort', () => child.kill()). If the AbortSignal from Tab B's queryAbortController somehow affects Tab A's child process (e.g., through Electron's cross-realm AbortSignal issues noted at customSpawn.ts:30-32), it could kill Tab A's CLI process.

Suggested Debugging Steps

  1. Add logging to closePersistentQuery() to confirm which service instance is being closed:

    closePersistentQuery(reason?: string) {
      console.log(`[closePQ] reason=${reason}, handlerCount=${this.responseHandlers.length}, consumerRunning=${this.responseConsumerRunning}`);
    }
  2. Add logging to routeMessage() when a turn completes with no handler:

    if (isTurnCompleteMessage(message)) {
      console.log(`[turnComplete] hasHandler=${!!handler}, bufferLen=${this._autoTurnBuffer.length}`);
    }
  3. Log when onDone() is called on a handler to find the premature termination:

    onDone: () => {
      console.log(`[handler.onDone] id=${handlerId}`, new Error().stack);
    }
  4. Check if multiple tab services' CLI processes share a PID group or session directory.

Key Files

Component File Lines
switchToTab() src/features/chat/tabs/TabManager.ts 148-203
setSessionId() src/core/agent/ClaudianService.ts 1475-1495
closePersistentQuery() src/core/agent/ClaudianService.ts 380-433
Response consumer loop src/core/agent/ClaudianService.ts 550-632
Handler yield loop src/core/agent/ClaudianService.ts 1057-1068
renderAutoTriggeredTurn() src/features/chat/tabs/Tab.ts 1203-1238
Custom spawn (abort → kill) src/core/agent/customSpawn.ts 40-46
deactivateTab() / activateTab() src/features/chat/tabs/Tab.ts 1043-1060

Related Issues

Note

This is particularly disruptive for long-running agent tasks (codebase exploration, multi-file operations) that can take 5-10+ minutes. Users naturally want to use other tabs while waiting, but doing so kills the running task.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions