alvin-bot 4.19.0 โ†’ 4.19.2

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/CHANGELOG.md CHANGED
@@ -2,6 +2,46 @@
2
2
 
3
3
  All notable changes to Alvin Bot are documented here.
4
4
 
5
+ ## [4.19.2] โ€” 2026-04-24
6
+
7
+ ### ๐Ÿ› Fix: workspace switch produced "(no response)" format-kaskade; added empty-stream diagnostics
8
+
9
+ **Symptom.** After v4.19.1 shipped, a workspace/dir switch still produced a broken response โ€” but this time NOT an empty stream. Claude replied with literal text like `"(no response)\n\nUser: Hallo"`, then the next turn `"(no response)\n\nUser: wie viele tools hast duโ€ฆ"` โ€” a format-kaskade where every response got worse.
10
+
11
+ **Root cause.** v4.19.1's cwd-change reset set `session.lastSdkHistoryIndex = -1`. That value is consumed by `buildBridgeMessage()` in `handlers/message.ts`, which is designed for the Ollama-fallback path โ€” its preamble frames past turns as *"the following N message(s) were exchanged with a fallback model"*. When the reset runs on a workspace switch, the ENTIRE conversation history (dozens of turns in a long-running session) gets packaged under that framing and prepended to the next prompt. If the history contains Telegram fallback artifacts (`(Keine Antwort)`, `(no response)`), Claude reads those as the "fallback model's response format" and imitates it. Each imitation lands back in history, poisoning the next bridge. Cascade.
12
+
13
+ Workspace switch is not a fallback event โ€” it's *"new persona, new task"*. The old conversation belongs to the old workspace and must not be reframed and re-injected.
14
+
15
+ **Fix.** `handlers/message.ts`, `handlers/platform-message.ts`, and `handlers/commands.ts` (`/dir`) now set `session.lastSdkHistoryIndex = session.history.length - 1` on cwd change. `buildBridgeMessage()` returns empty for the next turn, Claude starts the new workspace with a clean slate โ€” persona, cwd, system prompt, but no inherited conversation.
16
+
17
+ **Additionally โ€” empty-stream diagnostics.** `src/providers/claude-sdk-provider.ts` now logs a structured JSON dump on empty-stream detection: SDK result `subtype`/`is_error`/`num_turns`/`duration_ms`, the `usage` object, the `session_id` Claude returned vs. the one we passed, model override, cwd, effort, prompt/systemPrompt/history sizes, allowedTools count, and MCP state. Lets future empty-stream events be triaged in one log line instead of guessing.
18
+
19
+ **Net effect.** `/workspace <name>` โ†’ message โ†’ clean response (no Fallback-framed preamble, no format-kaskade). `/dir <path>` โ†’ same. Next empty-stream event will come with actionable diagnostic output instead of a silent symptom.
20
+
21
+ ## [4.19.1] โ€” 2026-04-24
22
+
23
+ ### ๐Ÿ› Critical fix: workspace/dir switch no longer produces empty-stream loop
24
+
25
+ **Problem:** After `/workspace <name>` (or `/dir <path>`), every subsequent SDK message returned `โš ๏ธ Claude antwortete mit leerem Stream โ€ฆ` โ€” and even switching back to the previous workspace did not recover. The v4.18.5 auto-reset only masked the symptom; the underlying cause survived the recovery attempt.
26
+
27
+ **Root cause โ€” a two-part bug:**
28
+
29
+ 1. **Prevention layer missing.** The Claude Agent SDK's `resume: <sessionId>` is bound to the cwd the session was created in: session files live under `~/.claude/projects/<cwd-hash>/<session-id>.jsonl`. When a workspace switch changes `session.workingDir`, the stored `session.sessionId` points at a file that no longer exists in the new project folder. The CLI silently returns an empty stream.
30
+ 2. **Recovery layer broken.** v4.18.5's empty-stream detector correctly cleared `session.sessionId = null` on the `text` chunk โ€” but the very next `done` chunk of the same stream carried `sessionId: resultMsg.session_id || capturedSessionId`, and the handler's `if (chunk.sessionId) session.sessionId = chunk.sessionId;` restored it. The "reset" was immediately undone by the trailing done chunk, so the next turn resumed the same dead session. Loop.
31
+
32
+ **Fix (defense in depth, three layers):**
33
+
34
+ - **Prevention** (root cause): `handlers/message.ts`, `handlers/platform-message.ts`, and `handlers/commands.ts` (`/dir`) now detect `session.workingDir !== workspace.cwd` (resp. new dir) BEFORE the query and clear `session.sessionId = null` + `session.lastSdkHistoryIndex = -1`. The next SDK turn starts fresh in the new project folder. `markSessionDirty()` is called so the clear persists across restarts.
35
+ - **Recovery**: both handlers now track a local `sessionResetInStream` flag. When the provider signals `sessionResetRequested` on a text chunk, the flag is set, and the subsequent `done` chunk's sessionId is ignored (the original resume token or the CLI's fresh-but-wrong-project fallback โ€” neither is safe).
36
+ - **Hygiene**: `markSessionDirty()` is also called from the empty-stream reset path so the cleared sessionId is persisted immediately rather than waiting for the next trackProviderUsage debounce.
37
+
38
+ **Net effect:** `/workspace <name>` โ†’ message โ†’ works. `/workspace default` โ†’ message โ†’ works. `/dir ~/Projects/foo` โ†’ message โ†’ works. No manual `/new` needed, no credit burn, no recovery retry.
39
+
40
+ **Files:**
41
+ - `src/handlers/message.ts` โ€” cwd-change detection, sessionResetInStream flag, done-chunk guard, markSessionDirty import
42
+ - `src/handlers/platform-message.ts` โ€” same set of changes for non-Telegram platforms (Slack, Discord, WhatsApp)
43
+ - `src/handlers/commands.ts` โ€” `/dir` now invalidates SDK resume anchor on cwd change
44
+
5
45
  ## [4.19.0] โ€” 2026-04-24
6
46
 
7
47
  ### ๐Ÿงญ Feature: per-workspace runtime overrides (effort ยท provider ยท voice ยท temperature ยท toolset)
@@ -227,7 +227,22 @@ export function registerCommands(bot) {
227
227
  ? path.join(os.homedir(), newDir.slice(1))
228
228
  : path.resolve(newDir);
229
229
  if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) {
230
+ // v4.19.1 โ€” Claude Agent SDK's `resume` is bound to the cwd. Changing
231
+ // the working dir without clearing the resume anchor would make the
232
+ // next SDK turn look up the session file in the wrong project folder
233
+ // โ†’ silent empty stream. Null out sessionId + history-anchor so the
234
+ // next turn starts a fresh SDK session in the new cwd.
235
+ const cwdChanged = session.workingDir !== resolved;
230
236
  session.workingDir = resolved;
237
+ if (cwdChanged) {
238
+ session.sessionId = null;
239
+ // v4.19.2 โ€” Anchor at current last turn so no catch-up bridge is
240
+ // generated for the next turn. /dir semantically means "switch
241
+ // project context" just like /workspace โ€” starting fresh is the
242
+ // sane default.
243
+ session.lastSdkHistoryIndex = session.history.length - 1;
244
+ }
245
+ markSessionDirty(userId);
231
246
  await ctx.reply(`Working directory: ${session.workingDir}`);
232
247
  }
233
248
  else {
@@ -1,6 +1,6 @@
1
1
  import { InputFile } from "grammy";
2
2
  import fs from "fs";
3
- import { getSession, addToHistory, trackProviderUsage, buildSessionKey, getTelegramWorkspace } from "../services/session.js";
3
+ import { getSession, addToHistory, trackProviderUsage, buildSessionKey, getTelegramWorkspace, markSessionDirty } from "../services/session.js";
4
4
  import { resolveWorkspaceOrDefault, getWorkspace } from "../services/workspaces.js";
5
5
  import { TelegramStreamer } from "../services/telegram.js";
6
6
  import { getRegistry } from "../engine.js";
@@ -313,9 +313,32 @@ export async function handleMessage(ctx) {
313
313
  const workspace = activeWsName
314
314
  ? (getWorkspace(activeWsName) ?? resolveWorkspaceOrDefault("telegram", String(userId), undefined))
315
315
  : resolveWorkspaceOrDefault("telegram", String(userId), undefined);
316
+ // v4.19.1 โ€” Workspace switch detection. Claude Agent SDK's `resume` is
317
+ // bound to the cwd (session files live under
318
+ // ~/.claude/projects/<cwd-hash>/<session-id>.jsonl). If cwd changes as
319
+ // part of this switch, the stored sessionId points at a file the CLI
320
+ // cannot find in the new project folder โ†’ silent empty stream. Guard
321
+ // with a workspaceName change (not cwd comparison) so /dir-initiated
322
+ // custom cwds are preserved across turns where no workspace actually
323
+ // switched.
316
324
  if (session.workspaceName !== workspace.name) {
325
+ const cwdChanged = session.workingDir !== workspace.cwd;
317
326
  session.workspaceName = workspace.name;
318
327
  session.workingDir = workspace.cwd;
328
+ if (cwdChanged) {
329
+ console.log(`[session] workspace switch changed cwd (โ†’ ${workspace.cwd}) โ€” ` +
330
+ `invalidating SDK resume anchor and skipping bridge`);
331
+ session.sessionId = null;
332
+ // v4.19.2 โ€” Anchor at the last turn BEFORE the new user message so
333
+ // buildBridgeMessage() produces no catch-up preamble. A workspace
334
+ // switch means "new persona, new task" โ€” the previous conversation
335
+ // (often from a different workspace) should NOT be reframed as
336
+ // "Fallback model turns" and fed back into Claude. That framing
337
+ // was producing format-kaskaden where Claude imitated Telegram
338
+ // "(Keine Antwort)" fallback artifacts from history.
339
+ session.lastSdkHistoryIndex = session.history.length - 1;
340
+ markSessionDirty(userId);
341
+ }
319
342
  }
320
343
  const chatIdStr = String(ctx.chat.id);
321
344
  const skillContext = buildSkillContext(text);
@@ -429,6 +452,12 @@ export async function handleMessage(ctx) {
429
452
  // readable description (which only appears in the tool_use input,
430
453
  // not in the tool_result text). See Fix #17 Stage 2.
431
454
  let lastAgentToolUseInput;
455
+ // v4.19.1 โ€” Track whether the provider requested a session reset during
456
+ // this stream. If it did, the trailing `done` chunk's sessionId MUST be
457
+ // ignored โ€” otherwise it restores the exact sessionId we just cleared
458
+ // (the empty-stream capturedSessionId) and the next turn loops again.
459
+ // This is the second half of the empty-stream-loop fix.
460
+ let sessionResetInStream = false;
432
461
  for await (const chunk of registry.queryWithFallback(queryOpts, workspace.provider)) {
433
462
  // v4.12.1 โ€” Update pending-sync-task state FIRST so the timer's
434
463
  // next reset picks up the new state. This ordering is load-bearing:
@@ -464,6 +493,8 @@ export async function handleMessage(ctx) {
464
493
  console.warn(`[session] provider requested reset for ${sessionKey} โ€” clearing sessionId + SDK anchor`);
465
494
  session.sessionId = null;
466
495
  session.lastSdkHistoryIndex = -1;
496
+ sessionResetInStream = true;
497
+ markSessionDirty(userId);
467
498
  }
468
499
  // Emit the new delta for observers โ€” accumulated text minus what
469
500
  // we already broadcast.
@@ -544,7 +575,16 @@ export async function handleMessage(ctx) {
544
575
  lastAgentToolUseInput = undefined;
545
576
  break;
546
577
  case "done":
547
- if (chunk.sessionId)
578
+ // v4.19.1 โ€” Respect the in-stream session reset. If the provider
579
+ // already signalled `sessionResetRequested` on the preceding text
580
+ // chunk (empty-stream detection), do NOT let the trailing done
581
+ // chunk restore the sessionId we just nulled โ€” that was the
582
+ // silent bug behind the empty-stream loop across workspace
583
+ // switches. The `done` chunk's sessionId on an empty stream is
584
+ // either the stale resume token we tried to use or a brand-new
585
+ // session file the CLI created in the wrong project folder;
586
+ // neither is safe to resume from.
587
+ if (chunk.sessionId && !sessionResetInStream)
548
588
  session.sessionId = chunk.sessionId;
549
589
  if (chunk.costUsd)
550
590
  session.totalCost += chunk.costUsd;
@@ -7,7 +7,7 @@
7
7
  * This is the platform-agnostic equivalent of message.ts (which is Telegram-specific).
8
8
  */
9
9
  import fs from "fs";
10
- import { getSession, addToHistory, trackProviderUsage, buildSessionKey } from "../services/session.js";
10
+ import { getSession, addToHistory, trackProviderUsage, buildSessionKey, markSessionDirty } from "../services/session.js";
11
11
  import { resolveWorkspaceOrDefault } from "../services/workspaces.js";
12
12
  import { getRegistry } from "../engine.js";
13
13
  import { buildSmartSystemPrompt } from "../services/personality.js";
@@ -114,11 +114,25 @@ export async function handlePlatformMessage(msg, adapter) {
114
114
  }
115
115
  }
116
116
  const workspace = resolveWorkspaceOrDefault(msg.platform, msg.chatId, channelName);
117
- // If the workspace changed since last turn, update the session's cwd +
118
- // workspaceName. This is debounced via session-persistence (v4.11.0).
117
+ // v4.19.1 โ€” Workspace switch detection. If cwd changes as part of the
118
+ // switch, null out session.sessionId so the next SDK turn does not
119
+ // resume a session file that lives in the previous project folder
120
+ // (Claude Agent SDK stores sessions under ~/.claude/projects/<cwd-hash>/).
121
+ // Guard with workspaceName so /dir-initiated custom cwds survive turns
122
+ // where no workspace actually switched.
119
123
  if (session.workspaceName !== workspace.name) {
124
+ const cwdChanged = session.workingDir !== workspace.cwd;
120
125
  session.workspaceName = workspace.name;
121
126
  session.workingDir = workspace.cwd;
127
+ if (cwdChanged) {
128
+ console.log(`[session] workspace switch changed cwd (โ†’ ${workspace.cwd}) โ€” ` +
129
+ `invalidating SDK resume anchor and skipping bridge`);
130
+ session.sessionId = null;
131
+ // v4.19.2 โ€” Anchor at current last turn so no catch-up bridge is
132
+ // generated for the next turn (see message.ts for full rationale).
133
+ session.lastSdkHistoryIndex = session.history.length - 1;
134
+ markSessionDirty(sessionKey);
135
+ }
122
136
  }
123
137
  // Skip if already processing (queue up to 3)
124
138
  if (session.isProcessing) {
@@ -195,6 +209,11 @@ export async function handlePlatformMessage(msg, adapter) {
195
209
  if (!isSDK) {
196
210
  addToHistory(sessionKey, { role: "user", content: fullText });
197
211
  }
212
+ // v4.19.1 โ€” Track whether the provider requested a session reset during
213
+ // this stream. If it did, the trailing `done` chunk's sessionId MUST be
214
+ // ignored โ€” otherwise it restores the exact sessionId we just cleared
215
+ // and the next turn loops again. Mirror of message.ts.
216
+ let sessionResetInStream = false;
198
217
  for await (const chunk of registry.queryWithFallback(queryOpts, workspace.provider)) {
199
218
  switch (chunk.type) {
200
219
  case "text":
@@ -205,10 +224,15 @@ export async function handlePlatformMessage(msg, adapter) {
205
224
  console.warn(`[session] provider requested reset for ${sessionKey} โ€” clearing sessionId + SDK anchor`);
206
225
  session.sessionId = null;
207
226
  session.lastSdkHistoryIndex = -1;
227
+ sessionResetInStream = true;
228
+ markSessionDirty(sessionKey);
208
229
  }
209
230
  break;
210
231
  case "done":
211
- if (chunk.sessionId)
232
+ // v4.19.1 โ€” Respect in-stream reset: don't let done.sessionId undo
233
+ // the clear from the empty-stream text chunk. See message.ts for
234
+ // full rationale.
235
+ if (chunk.sessionId && !sessionResetInStream)
212
236
  session.sessionId = chunk.sessionId;
213
237
  if (chunk.costUsd)
214
238
  session.totalCost += chunk.costUsd;
@@ -380,6 +380,37 @@ export class ClaudeSDKProvider {
380
380
  // and knows to resend โ€” without tripping the failover.
381
381
  if (accumulatedText === "" && outputTok === 0) {
382
382
  this.invalidateAvailabilityCache();
383
+ // v4.19.2 โ€” Diagnostic logging: when the Agent SDK returns an
384
+ // empty stream, we need enough detail to tell apart the possible
385
+ // causes (auth, quota, context overflow, model rejection, MCP
386
+ // init failure). The message handler-level reset was fine for
387
+ // stale-session recovery but gave us no signal on WHY it went
388
+ // empty in the first place.
389
+ try {
390
+ const diag = {
391
+ subtype: resultMsg.subtype,
392
+ is_error: resultMsg.is_error,
393
+ num_turns: resultMsg.num_turns,
394
+ duration_ms: resultMsg.duration_ms,
395
+ duration_api_ms: resultMsg.duration_api_ms,
396
+ total_cost_usd: resultMsg.total_cost_usd,
397
+ session_id: resultMsg.session_id,
398
+ passed_session_id: options.sessionId ?? null,
399
+ usage,
400
+ modelOverride,
401
+ cwd: options.workingDir,
402
+ effort: options.effort,
403
+ systemPromptLen: systemPrompt.length,
404
+ promptLen: prompt.length,
405
+ historyLen: options.history?.length ?? 0,
406
+ allowedToolsCount: (options.allowedTools ?? defaultAllowed).length,
407
+ hasMcp: Object.keys(mcpServers).length > 0,
408
+ };
409
+ console.warn(`[empty-stream] SDK returned 0 output tokens โ€” diagnostic dump:`, JSON.stringify(diag));
410
+ }
411
+ catch (diagErr) {
412
+ console.warn(`[empty-stream] SDK returned 0 output tokens (diagnostic serialisation failed: ${diagErr})`);
413
+ }
383
414
  const hint = "โš ๏ธ Claude antwortete mit leerem Stream. " +
384
415
  "Meist Folge einer stale SDK-Session nach /extra-usage, /login oder Token-Refresh. " +
385
416
  "Ich starte die Session automatisch neu โ€” bitte schick die Nachricht einfach nochmal.";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "alvin-bot",
3
- "version": "4.19.0",
3
+ "version": "4.19.2",
4
4
  "description": "Alvin Bot \u2014 Your personal AI agent on Telegram, WhatsApp, Discord, Signal, and Web.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",