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 {
|
package/dist/handlers/message.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
118
|
-
//
|
|
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
|
-
|
|
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.";
|