Merge branch 'main' into vincentkoc-code/fix-issue-21236-legacy-paired-metadata

This commit is contained in:
Val Alexander
2026-02-19 17:05:45 -06:00
committed by GitHub
72 changed files with 1981 additions and 132 deletions

View File

@@ -7,11 +7,13 @@ Docs: https://docs.openclaw.ai
### Changes
- iOS/Gateway: stabilize background wake and reconnect behavior with background reconnect suppression/lease windows, BGAppRefresh wake fallback, location wake hook throttling, and APNs wake retry+nudge instrumentation. (#21226) thanks @mbelinky.
- Auto-reply/UI: add model fallback lifecycle visibility in verbose logs, /status active-model context with fallback reason, and cohesive WebUI fallback indicators. (#20704) Thanks @joshavant.
### Fixes
- Docker: pin base images to SHA256 digests in Docker builds to prevent mutable tag drift. (#7734) Thanks @coygeek.
- Provider/HTTP: treat HTTP 503 as failover-eligible for LLM provider errors. (#21086) Thanks @Protocol-zero-0.
- Slack: pass `recipient_team_id` / `recipient_user_id` through Slack native streaming calls so `chat.startStream`/`appendStream`/`stopStream` work reliably across DMs and Slack Connect setups, and disable block streaming when native streaming is active. (#20988) Thanks @Dithilli. Earlier recipient-ID groundwork was contributed in #20377 by @AsserAl1012.
- Discord/Gateway: handle close code 4014 (missing privileged gateway intents) without crashing the gateway. Thanks @thewilloftheshadow.
- Security/Net: strip sensitive headers (`Authorization`, `Proxy-Authorization`, `Cookie`, `Cookie2`) on cross-origin redirects in `fetchWithSsrFGuard` to prevent credential forwarding across origin boundaries. (#20313) Thanks @afurm.

View File

@@ -21,8 +21,8 @@ android {
applicationId = "ai.openclaw.android"
minSdk = 31
targetSdk = 36
versionCode = 202602190
versionName = "2026.2.19"
versionCode = 202602200
versionName = "2026.2.20"
ndk {
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")

View File

@@ -17,9 +17,9 @@
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>2026.2.19</string>
<string>2026.2.20</string>
<key>CFBundleVersion</key>
<string>20260219</string>
<string>20260220</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>

View File

@@ -19,7 +19,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.2.19</string>
<string>2026.2.20</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
@@ -32,7 +32,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>20260219</string>
<string>20260220</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoadsInWebContent</key>

View File

@@ -17,8 +17,8 @@
<key>CFBundlePackageType</key>
<string>BNDL</string>
<key>CFBundleShortVersionString</key>
<string>2026.2.19</string>
<string>2026.2.20</string>
<key>CFBundleVersion</key>
<string>20260219</string>
<string>20260220</string>
</dict>
</plist>

View File

@@ -17,9 +17,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.2.19</string>
<string>2026.2.20</string>
<key>CFBundleVersion</key>
<string>20260219</string>
<string>20260220</string>
<key>WKCompanionAppBundleIdentifier</key>
<string>$(OPENCLAW_APP_BUNDLE_ID)</string>
<key>WKWatchKitApp</key>

View File

@@ -15,9 +15,9 @@
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundleShortVersionString</key>
<string>2026.2.19</string>
<string>2026.2.20</string>
<key>CFBundleVersion</key>
<string>20260219</string>
<string>20260220</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>

View File

@@ -92,8 +92,8 @@ targets:
- CFBundleURLName: ai.openclaw.ios
CFBundleURLSchemes:
- openclaw
CFBundleShortVersionString: "2026.2.19"
CFBundleVersion: "20260219"
CFBundleShortVersionString: "2026.2.20"
CFBundleVersion: "20260220"
UILaunchScreen: {}
UIApplicationSceneManifest:
UIApplicationSupportsMultipleScenes: false
@@ -144,8 +144,8 @@ targets:
path: ShareExtension/Info.plist
properties:
CFBundleDisplayName: OpenClaw Share
CFBundleShortVersionString: "2026.2.19"
CFBundleVersion: "20260219"
CFBundleShortVersionString: "2026.2.20"
CFBundleVersion: "20260220"
NSExtension:
NSExtensionPointIdentifier: com.apple.share-services
NSExtensionPrincipalClass: "$(PRODUCT_MODULE_NAME).ShareViewController"
@@ -174,8 +174,8 @@ targets:
path: WatchApp/Info.plist
properties:
CFBundleDisplayName: OpenClaw
CFBundleShortVersionString: "2026.2.19"
CFBundleVersion: "20260219"
CFBundleShortVersionString: "2026.2.20"
CFBundleVersion: "20260220"
WKCompanionAppBundleIdentifier: "$(OPENCLAW_APP_BUNDLE_ID)"
WKWatchKitApp: true
@@ -198,8 +198,8 @@ targets:
path: WatchExtension/Info.plist
properties:
CFBundleDisplayName: OpenClaw
CFBundleShortVersionString: "2026.2.19"
CFBundleVersion: "20260219"
CFBundleShortVersionString: "2026.2.20"
CFBundleVersion: "20260220"
NSExtension:
NSExtensionAttributes:
WKAppBundleIdentifier: "$(OPENCLAW_WATCH_APP_BUNDLE_ID)"
@@ -226,5 +226,5 @@ targets:
path: Tests/Info.plist
properties:
CFBundleDisplayName: OpenClawTests
CFBundleShortVersionString: "2026.2.19"
CFBundleVersion: "20260219"
CFBundleShortVersionString: "2026.2.20"
CFBundleVersion: "20260220"

View File

@@ -15,9 +15,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.2.19</string>
<string>2026.2.20</string>
<key>CFBundleVersion</key>
<string>202602190</string>
<string>202602200</string>
<key>CFBundleIconFile</key>
<string>OpenClaw</string>
<key>CFBundleURLTypes</key>

View File

@@ -34,17 +34,17 @@ Notes:
# From repo root; set release IDs so Sparkle feed is enabled.
# APP_BUILD must be numeric + monotonic for Sparkle compare.
BUNDLE_ID=bot.molt.mac \
APP_VERSION=2026.2.19 \
APP_VERSION=2026.2.20 \
APP_BUILD="$(git rev-list --count HEAD)" \
BUILD_CONFIG=release \
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
scripts/package-mac-app.sh
# Zip for distribution (includes resource forks for Sparkle delta support)
ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.2.19.zip
ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.2.20.zip
# Optional: also build a styled DMG for humans (drag to /Applications)
scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.2.19.dmg
scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.2.20.dmg
# Recommended: build + notarize/staple zip + DMG
# First, create a keychain profile once:
@@ -52,14 +52,14 @@ scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.2.19.dmg
# --apple-id "<apple-id>" --team-id "<team-id>" --password "<app-specific-password>"
NOTARIZE=1 NOTARYTOOL_PROFILE=openclaw-notary \
BUNDLE_ID=bot.molt.mac \
APP_VERSION=2026.2.19 \
APP_VERSION=2026.2.20 \
APP_BUILD="$(git rev-list --count HEAD)" \
BUILD_CONFIG=release \
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
scripts/package-mac-dist.sh
# Optional: ship dSYM alongside the release
ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.2.19.dSYM.zip
ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.2.20.dSYM.zip
```
## Appcast entry
@@ -67,7 +67,7 @@ ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenCl
Use the release note generator so Sparkle renders formatted HTML notes:
```bash
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/OpenClaw-2026.2.19.zip https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/OpenClaw-2026.2.20.zip https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml
```
Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/openclaw/openclaw/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry.
@@ -75,7 +75,7 @@ Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when
## Publish & verify
- Upload `OpenClaw-2026.2.19.zip` (and `OpenClaw-2026.2.19.dSYM.zip`) to the GitHub release for tag `v2026.2.19`.
- Upload `OpenClaw-2026.2.20.zip` (and `OpenClaw-2026.2.20.dSYM.zip`) to the GitHub release for tag `v2026.2.20`.
- Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml`.
- Sanity checks:
- `curl -I https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml` returns 200.

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/bluebubbles",
"version": "2026.2.19",
"version": "2026.2.20",
"description": "OpenClaw BlueBubbles channel plugin",
"type": "module",
"devDependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/copilot-proxy",
"version": "2026.2.19",
"version": "2026.2.20",
"private": true,
"description": "OpenClaw Copilot Proxy provider plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/diagnostics-otel",
"version": "2026.2.19",
"version": "2026.2.20",
"description": "OpenClaw diagnostics OpenTelemetry exporter",
"type": "module",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/discord",
"version": "2026.2.19",
"version": "2026.2.20",
"description": "OpenClaw Discord channel plugin",
"type": "module",
"devDependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/feishu",
"version": "2026.2.19",
"version": "2026.2.20",
"description": "OpenClaw Feishu/Lark channel plugin (community maintained by @m1heng)",
"type": "module",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/google-antigravity-auth",
"version": "2026.2.19",
"version": "2026.2.20",
"private": true,
"description": "OpenClaw Google Antigravity OAuth provider plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/google-gemini-cli-auth",
"version": "2026.2.19",
"version": "2026.2.20",
"private": true,
"description": "OpenClaw Gemini CLI OAuth provider plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/googlechat",
"version": "2026.2.19",
"version": "2026.2.20",
"private": true,
"description": "OpenClaw Google Chat channel plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/imessage",
"version": "2026.2.19",
"version": "2026.2.20",
"private": true,
"description": "OpenClaw iMessage channel plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/irc",
"version": "2026.2.19",
"version": "2026.2.20",
"description": "OpenClaw IRC channel plugin",
"type": "module",
"devDependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/line",
"version": "2026.2.19",
"version": "2026.2.20",
"private": true,
"description": "OpenClaw LINE channel plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/llm-task",
"version": "2026.2.19",
"version": "2026.2.20",
"private": true,
"description": "OpenClaw JSON-only LLM task plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/lobster",
"version": "2026.2.19",
"version": "2026.2.20",
"description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)",
"type": "module",
"devDependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/matrix",
"version": "2026.2.19",
"version": "2026.2.20",
"description": "OpenClaw Matrix channel plugin",
"type": "module",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/mattermost",
"version": "2026.2.19",
"version": "2026.2.20",
"private": true,
"description": "OpenClaw Mattermost channel plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/memory-core",
"version": "2026.2.19",
"version": "2026.2.20",
"private": true,
"description": "OpenClaw core memory search plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/memory-lancedb",
"version": "2026.2.19",
"version": "2026.2.20",
"private": true,
"description": "OpenClaw LanceDB-backed long-term memory plugin with auto-recall/capture",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/minimax-portal-auth",
"version": "2026.2.19",
"version": "2026.2.20",
"private": true,
"description": "OpenClaw MiniMax Portal OAuth provider plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/msteams",
"version": "2026.2.19",
"version": "2026.2.20",
"description": "OpenClaw Microsoft Teams channel plugin",
"type": "module",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/nextcloud-talk",
"version": "2026.2.19",
"version": "2026.2.20",
"description": "OpenClaw Nextcloud Talk channel plugin",
"type": "module",
"devDependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/nostr",
"version": "2026.2.19",
"version": "2026.2.20",
"description": "OpenClaw Nostr channel plugin for NIP-04 encrypted DMs",
"type": "module",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/open-prose",
"version": "2026.2.19",
"version": "2026.2.20",
"private": true,
"description": "OpenProse VM skill pack plugin (slash command + telemetry).",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/signal",
"version": "2026.2.19",
"version": "2026.2.20",
"private": true,
"description": "OpenClaw Signal channel plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/slack",
"version": "2026.2.19",
"version": "2026.2.20",
"private": true,
"description": "OpenClaw Slack channel plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/telegram",
"version": "2026.2.19",
"version": "2026.2.20",
"private": true,
"description": "OpenClaw Telegram channel plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/tlon",
"version": "2026.2.19",
"version": "2026.2.20",
"private": true,
"description": "OpenClaw Tlon/Urbit channel plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/twitch",
"version": "2026.2.19",
"version": "2026.2.20",
"private": true,
"description": "OpenClaw Twitch channel plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/voice-call",
"version": "2026.2.19",
"version": "2026.2.20",
"description": "OpenClaw voice-call plugin",
"type": "module",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/whatsapp",
"version": "2026.2.19",
"version": "2026.2.20",
"private": true,
"description": "OpenClaw WhatsApp channel plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/zalo",
"version": "2026.2.19",
"version": "2026.2.20",
"description": "OpenClaw Zalo channel plugin",
"type": "module",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/zalouser",
"version": "2026.2.19",
"version": "2026.2.20",
"description": "OpenClaw Zalo Personal Account plugin via zca-cli",
"type": "module",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "openclaw",
"version": "2026.2.19",
"version": "2026.2.20",
"description": "Multi-channel AI gateway with extensible messaging integrations",
"keywords": [],
"homepage": "https://github.com/openclaw/openclaw#readme",

View File

@@ -13,7 +13,7 @@ metadata:
{
"id": "brew-cask",
"kind": "brew",
"cask": "steipete/tap/codexbar",
"formula": "steipete/tap/codexbar",
"bins": ["codexbar"],
"label": "Install CodexBar (brew cask)",
},

View File

@@ -45,8 +45,13 @@ function parseInstallSpec(input: unknown): SkillInstallSpec | undefined {
if (osList.length > 0) {
spec.os = osList;
}
if (typeof raw.formula === "string") {
spec.formula = raw.formula;
const formula = typeof raw.formula === "string" ? raw.formula.trim() : "";
if (formula) {
spec.formula = formula;
}
const cask = typeof raw.cask === "string" ? raw.cask.trim() : "";
if (!spec.formula && cask) {
spec.formula = cask;
}
if (typeof raw.package === "string") {
spec.package = raw.package;

View File

@@ -0,0 +1,123 @@
import { describe, expect, it } from "vitest";
import {
resolveActiveFallbackState,
resolveFallbackTransition,
type FallbackNoticeState,
} from "./fallback-state.js";
const baseAttempt = {
provider: "fireworks",
model: "fireworks/minimax-m2p5",
error: "Provider fireworks is in cooldown (all profiles unavailable)",
reason: "rate_limit" as const,
};
describe("fallback-state", () => {
it("treats fallback as active only when state matches selected and active refs", () => {
const state: FallbackNoticeState = {
fallbackNoticeSelectedModel: "fireworks/minimax-m2p5",
fallbackNoticeActiveModel: "deepinfra/moonshotai/Kimi-K2.5",
fallbackNoticeReason: "rate limit",
};
const resolved = resolveActiveFallbackState({
selectedModelRef: "fireworks/minimax-m2p5",
activeModelRef: "deepinfra/moonshotai/Kimi-K2.5",
state,
});
expect(resolved.active).toBe(true);
expect(resolved.reason).toBe("rate limit");
});
it("does not treat runtime drift as fallback when persisted state does not match", () => {
const state: FallbackNoticeState = {
fallbackNoticeSelectedModel: "anthropic/claude",
fallbackNoticeActiveModel: "deepinfra/moonshotai/Kimi-K2.5",
fallbackNoticeReason: "rate limit",
};
const resolved = resolveActiveFallbackState({
selectedModelRef: "fireworks/minimax-m2p5",
activeModelRef: "deepinfra/moonshotai/Kimi-K2.5",
state,
});
expect(resolved.active).toBe(false);
expect(resolved.reason).toBeUndefined();
});
it("marks fallback transition when selected->active pair changes", () => {
const resolved = resolveFallbackTransition({
selectedProvider: "fireworks",
selectedModel: "fireworks/minimax-m2p5",
activeProvider: "deepinfra",
activeModel: "moonshotai/Kimi-K2.5",
attempts: [baseAttempt],
state: {},
});
expect(resolved.fallbackActive).toBe(true);
expect(resolved.fallbackTransitioned).toBe(true);
expect(resolved.fallbackCleared).toBe(false);
expect(resolved.stateChanged).toBe(true);
expect(resolved.reasonSummary).toBe("rate limit");
expect(resolved.nextState.selectedModel).toBe("fireworks/minimax-m2p5");
expect(resolved.nextState.activeModel).toBe("deepinfra/moonshotai/Kimi-K2.5");
});
it("normalizes fallback reason whitespace for summaries", () => {
const resolved = resolveFallbackTransition({
selectedProvider: "fireworks",
selectedModel: "fireworks/minimax-m2p5",
activeProvider: "deepinfra",
activeModel: "moonshotai/Kimi-K2.5",
attempts: [{ ...baseAttempt, reason: "rate_limit\n\tburst" }],
state: {},
});
expect(resolved.reasonSummary).toBe("rate limit burst");
});
it("refreshes reason when fallback remains active with same model pair", () => {
const resolved = resolveFallbackTransition({
selectedProvider: "fireworks",
selectedModel: "fireworks/minimax-m2p5",
activeProvider: "deepinfra",
activeModel: "moonshotai/Kimi-K2.5",
attempts: [{ ...baseAttempt, reason: "timeout" }],
state: {
fallbackNoticeSelectedModel: "fireworks/minimax-m2p5",
fallbackNoticeActiveModel: "deepinfra/moonshotai/Kimi-K2.5",
fallbackNoticeReason: "rate limit",
},
});
expect(resolved.fallbackTransitioned).toBe(false);
expect(resolved.stateChanged).toBe(true);
expect(resolved.nextState.reason).toBe("timeout");
});
it("marks fallback as cleared when runtime returns to selected model", () => {
const resolved = resolveFallbackTransition({
selectedProvider: "fireworks",
selectedModel: "fireworks/minimax-m2p5",
activeProvider: "fireworks",
activeModel: "fireworks/minimax-m2p5",
attempts: [],
state: {
fallbackNoticeSelectedModel: "fireworks/minimax-m2p5",
fallbackNoticeActiveModel: "deepinfra/moonshotai/Kimi-K2.5",
fallbackNoticeReason: "rate limit",
},
});
expect(resolved.fallbackActive).toBe(false);
expect(resolved.fallbackCleared).toBe(true);
expect(resolved.fallbackTransitioned).toBe(false);
expect(resolved.stateChanged).toBe(true);
expect(resolved.nextState.selectedModel).toBeUndefined();
expect(resolved.nextState.activeModel).toBeUndefined();
expect(resolved.nextState.reason).toBeUndefined();
});
});

View File

@@ -0,0 +1,180 @@
import type { SessionEntry } from "../config/sessions.js";
import { formatProviderModelRef } from "./model-runtime.js";
import type { RuntimeFallbackAttempt } from "./reply/agent-runner-execution.js";
const FALLBACK_REASON_PART_MAX = 80;
export type FallbackNoticeState = Pick<
SessionEntry,
"fallbackNoticeSelectedModel" | "fallbackNoticeActiveModel" | "fallbackNoticeReason"
>;
export function normalizeFallbackModelRef(value?: string): string | undefined {
const trimmed = String(value ?? "").trim();
return trimmed || undefined;
}
function truncateFallbackReasonPart(value: string, max = FALLBACK_REASON_PART_MAX): string {
const text = String(value ?? "")
.replace(/\s+/g, " ")
.trim();
if (text.length <= max) {
return text;
}
return `${text.slice(0, Math.max(0, max - 1)).trimEnd()}`;
}
export function formatFallbackAttemptReason(attempt: RuntimeFallbackAttempt): string {
const reason = attempt.reason?.trim();
if (reason) {
return reason.replace(/_/g, " ");
}
const code = attempt.code?.trim();
if (code) {
return code;
}
if (typeof attempt.status === "number") {
return `HTTP ${attempt.status}`;
}
return truncateFallbackReasonPart(attempt.error || "error");
}
function formatFallbackAttemptSummary(attempt: RuntimeFallbackAttempt): string {
return `${formatProviderModelRef(attempt.provider, attempt.model)} ${formatFallbackAttemptReason(attempt)}`;
}
export function buildFallbackReasonSummary(attempts: RuntimeFallbackAttempt[]): string {
const firstAttempt = attempts[0];
const firstReason = firstAttempt
? formatFallbackAttemptReason(firstAttempt)
: "selected model unavailable";
const moreAttempts = attempts.length > 1 ? ` (+${attempts.length - 1} more attempts)` : "";
return `${truncateFallbackReasonPart(firstReason)}${moreAttempts}`;
}
export function buildFallbackAttemptSummaries(attempts: RuntimeFallbackAttempt[]): string[] {
return attempts.map((attempt) =>
truncateFallbackReasonPart(formatFallbackAttemptSummary(attempt)),
);
}
export function buildFallbackNotice(params: {
selectedProvider: string;
selectedModel: string;
activeProvider: string;
activeModel: string;
attempts: RuntimeFallbackAttempt[];
}): string | null {
const selected = formatProviderModelRef(params.selectedProvider, params.selectedModel);
const active = formatProviderModelRef(params.activeProvider, params.activeModel);
if (selected === active) {
return null;
}
const reasonSummary = buildFallbackReasonSummary(params.attempts);
return `↪️ Model Fallback: ${active} (selected ${selected}; ${reasonSummary})`;
}
export function buildFallbackClearedNotice(params: {
selectedProvider: string;
selectedModel: string;
previousActiveModel?: string;
}): string {
const selected = formatProviderModelRef(params.selectedProvider, params.selectedModel);
const previous = normalizeFallbackModelRef(params.previousActiveModel);
if (previous && previous !== selected) {
return `↪️ Model Fallback cleared: ${selected} (was ${previous})`;
}
return `↪️ Model Fallback cleared: ${selected}`;
}
export function resolveActiveFallbackState(params: {
selectedModelRef: string;
activeModelRef: string;
state?: FallbackNoticeState;
}): { active: boolean; reason?: string } {
const selected = normalizeFallbackModelRef(params.state?.fallbackNoticeSelectedModel);
const active = normalizeFallbackModelRef(params.state?.fallbackNoticeActiveModel);
const reason = normalizeFallbackModelRef(params.state?.fallbackNoticeReason);
const fallbackActive =
params.selectedModelRef !== params.activeModelRef &&
selected === params.selectedModelRef &&
active === params.activeModelRef;
return {
active: fallbackActive,
reason: fallbackActive ? reason : undefined,
};
}
export type ResolvedFallbackTransition = {
selectedModelRef: string;
activeModelRef: string;
fallbackActive: boolean;
fallbackTransitioned: boolean;
fallbackCleared: boolean;
reasonSummary: string;
attemptSummaries: string[];
previousState: {
selectedModel?: string;
activeModel?: string;
reason?: string;
};
nextState: {
selectedModel?: string;
activeModel?: string;
reason?: string;
};
stateChanged: boolean;
};
export function resolveFallbackTransition(params: {
selectedProvider: string;
selectedModel: string;
activeProvider: string;
activeModel: string;
attempts: RuntimeFallbackAttempt[];
state?: FallbackNoticeState;
}): ResolvedFallbackTransition {
const selectedModelRef = formatProviderModelRef(params.selectedProvider, params.selectedModel);
const activeModelRef = formatProviderModelRef(params.activeProvider, params.activeModel);
const previousState = {
selectedModel: normalizeFallbackModelRef(params.state?.fallbackNoticeSelectedModel),
activeModel: normalizeFallbackModelRef(params.state?.fallbackNoticeActiveModel),
reason: normalizeFallbackModelRef(params.state?.fallbackNoticeReason),
};
const fallbackActive = selectedModelRef !== activeModelRef;
const fallbackTransitioned =
fallbackActive &&
(previousState.selectedModel !== selectedModelRef ||
previousState.activeModel !== activeModelRef);
const fallbackCleared =
!fallbackActive && Boolean(previousState.selectedModel || previousState.activeModel);
const reasonSummary = buildFallbackReasonSummary(params.attempts);
const attemptSummaries = buildFallbackAttemptSummaries(params.attempts);
const nextState = fallbackActive
? {
selectedModel: selectedModelRef,
activeModel: activeModelRef,
reason: reasonSummary,
}
: {
selectedModel: undefined,
activeModel: undefined,
reason: undefined,
};
const stateChanged =
previousState.selectedModel !== nextState.selectedModel ||
previousState.activeModel !== nextState.activeModel ||
previousState.reason !== nextState.reason;
return {
selectedModelRef,
activeModelRef,
fallbackActive,
fallbackTransitioned,
fallbackCleared,
reasonSummary,
attemptSummaries,
previousState,
nextState,
stateChanged,
};
}

View File

@@ -0,0 +1,93 @@
import type { SessionEntry } from "../config/sessions.js";
export function formatProviderModelRef(providerRaw: string, modelRaw: string): string {
const provider = String(providerRaw ?? "").trim();
const model = String(modelRaw ?? "").trim();
if (!provider) {
return model;
}
if (!model) {
return provider;
}
const prefix = `${provider}/`;
if (model.toLowerCase().startsWith(prefix.toLowerCase())) {
const normalizedModel = model.slice(prefix.length).trim();
if (normalizedModel) {
return `${provider}/${normalizedModel}`;
}
}
return `${provider}/${model}`;
}
type ModelRef = {
provider: string;
model: string;
label: string;
};
function normalizeModelWithinProvider(provider: string, modelRaw: string): string {
const model = String(modelRaw ?? "").trim();
if (!provider || !model) {
return model;
}
const prefix = `${provider}/`;
if (model.toLowerCase().startsWith(prefix.toLowerCase())) {
const withoutPrefix = model.slice(prefix.length).trim();
if (withoutPrefix) {
return withoutPrefix;
}
}
return model;
}
function normalizeModelRef(
rawModel: string,
fallbackProvider: string,
parseEmbeddedProvider = false,
): ModelRef {
const trimmed = String(rawModel ?? "").trim();
const slashIndex = parseEmbeddedProvider ? trimmed.indexOf("/") : -1;
if (slashIndex > 0) {
const provider = trimmed.slice(0, slashIndex).trim();
const model = trimmed.slice(slashIndex + 1).trim();
if (provider && model) {
return {
provider,
model,
label: `${provider}/${model}`,
};
}
}
const provider = String(fallbackProvider ?? "").trim();
const dedupedModel = normalizeModelWithinProvider(provider, trimmed);
return {
provider,
model: dedupedModel || trimmed,
label: provider ? formatProviderModelRef(provider, dedupedModel || trimmed) : trimmed,
};
}
export function resolveSelectedAndActiveModel(params: {
selectedProvider: string;
selectedModel: string;
sessionEntry?: Pick<SessionEntry, "modelProvider" | "model">;
}): {
selected: ModelRef;
active: ModelRef;
activeDiffers: boolean;
} {
const selected = normalizeModelRef(params.selectedModel, params.selectedProvider);
const runtimeModel = params.sessionEntry?.model?.trim();
const runtimeProvider = params.sessionEntry?.modelProvider?.trim();
const active = runtimeModel
? normalizeModelRef(runtimeModel, runtimeProvider || selected.provider, !runtimeProvider)
: selected;
const activeDiffers = active.provider !== selected.provider || active.model !== selected.model;
return {
selected,
active,
activeDiffers,
};
}

View File

@@ -40,12 +40,23 @@ import type { FollowupRun } from "./queue.js";
import { createBlockReplyDeliveryHandler } from "./reply-delivery.js";
import type { TypingSignaler } from "./typing-mode.js";
export type RuntimeFallbackAttempt = {
provider: string;
model: string;
error: string;
reason?: string;
status?: number;
code?: string;
};
export type AgentRunLoopResult =
| {
kind: "success";
runId: string;
runResult: Awaited<ReturnType<typeof runEmbeddedPiAgent>>;
fallbackProvider?: string;
fallbackModel?: string;
fallbackAttempts: RuntimeFallbackAttempt[];
didLogHeartbeatStrip: boolean;
autoCompactionCompleted: boolean;
/** Payload keys sent directly (not via pipeline) during tool flush. */
@@ -106,6 +117,7 @@ export async function runAgentTurnWithFallback(params: {
let runResult: Awaited<ReturnType<typeof runEmbeddedPiAgent>>;
let fallbackProvider = params.followupRun.run.provider;
let fallbackModel = params.followupRun.run.model;
let fallbackAttempts: RuntimeFallbackAttempt[] = [];
let didResetAfterCompactionFailure = false;
let didRetryTransientHttpError = false;
@@ -397,6 +409,16 @@ export async function runAgentTurnWithFallback(params: {
runResult = fallbackResult.result;
fallbackProvider = fallbackResult.provider;
fallbackModel = fallbackResult.model;
fallbackAttempts = Array.isArray(fallbackResult.attempts)
? fallbackResult.attempts.map((attempt) => ({
provider: String(attempt.provider ?? ""),
model: String(attempt.model ?? ""),
error: String(attempt.error ?? ""),
reason: attempt.reason ? String(attempt.reason) : undefined,
status: typeof attempt.status === "number" ? attempt.status : undefined,
code: attempt.code ? String(attempt.code) : undefined,
}))
: [];
// Some embedded runs surface context overflow as an error payload instead of throwing.
// Treat those as a session-level failure and auto-recover by starting a fresh session.
@@ -543,9 +565,11 @@ export async function runAgentTurnWithFallback(params: {
return {
kind: "success",
runId,
runResult,
fallbackProvider,
fallbackModel,
fallbackAttempts,
didLogHeartbeatStrip,
autoCompactionCompleted,
directlySentBlockKeys: directlySentBlockKeys.size > 0 ? directlySentBlockKeys : undefined,

View File

@@ -54,6 +54,7 @@ vi.mock("../../agents/model-fallback.js", () => ({
result: await run(provider, model),
provider,
model,
attempts: [],
}),
}));
@@ -508,6 +509,30 @@ describe("runReplyAgent typing (heartbeat)", () => {
expect(onToolResult).not.toHaveBeenCalled();
});
it("retries transient HTTP failures once with timer-driven backoff", async () => {
vi.useFakeTimers();
let calls = 0;
state.runEmbeddedPiAgentMock.mockImplementation(async () => {
calls += 1;
if (calls === 1) {
throw new Error("502 Bad Gateway");
}
return { payloads: [{ text: "final" }], meta: {} };
});
const { run } = createMinimalRun({
typingMode: "message",
});
const runPromise = run();
await vi.advanceTimersByTimeAsync(2_499);
expect(calls).toBe(1);
await vi.advanceTimersByTimeAsync(1);
await runPromise;
expect(calls).toBe(2);
vi.useRealTimers();
});
it("announces auto-compaction in verbose mode and tracks count", async () => {
await withTempStateDir(async (stateDir) => {
const storePath = path.join(stateDir, "sessions", "sessions.json");
@@ -538,12 +563,482 @@ describe("runReplyAgent typing (heartbeat)", () => {
});
});
it("announces model fallback in verbose mode", async () => {
const sessionEntry: SessionEntry = {
sessionId: "session",
updatedAt: Date.now(),
};
const sessionStore = { main: sessionEntry };
state.runEmbeddedPiAgentMock.mockResolvedValueOnce({ payloads: [{ text: "final" }], meta: {} });
const modelFallback = await import("../../agents/model-fallback.js");
vi.spyOn(modelFallback, "runWithModelFallback").mockImplementationOnce(
async ({ run }: { run: (provider: string, model: string) => Promise<unknown> }) => ({
result: await run("deepinfra", "moonshotai/Kimi-K2.5"),
provider: "deepinfra",
model: "moonshotai/Kimi-K2.5",
attempts: [
{
provider: "fireworks",
model: "fireworks/minimax-m2p5",
error: "Provider fireworks is in cooldown (all profiles unavailable)",
reason: "rate_limit",
},
],
}),
);
const { run } = createMinimalRun({
resolvedVerboseLevel: "on",
sessionEntry,
sessionStore,
sessionKey: "main",
});
const res = await run();
expect(Array.isArray(res)).toBe(true);
const payloads = res as { text?: string }[];
expect(payloads[0]?.text).toContain("Model Fallback:");
expect(payloads[0]?.text).toContain("deepinfra/moonshotai/Kimi-K2.5");
expect(sessionEntry.fallbackNoticeReason).toBe("rate limit");
});
it("does not announce model fallback when verbose is off", async () => {
const { onAgentEvent } = await import("../../infra/agent-events.js");
state.runEmbeddedPiAgentMock.mockResolvedValueOnce({ payloads: [{ text: "final" }], meta: {} });
const modelFallback = await import("../../agents/model-fallback.js");
vi.spyOn(modelFallback, "runWithModelFallback").mockImplementationOnce(
async ({ run }: { run: (provider: string, model: string) => Promise<unknown> }) => ({
result: await run("deepinfra", "moonshotai/Kimi-K2.5"),
provider: "deepinfra",
model: "moonshotai/Kimi-K2.5",
attempts: [
{
provider: "fireworks",
model: "fireworks/minimax-m2p5",
error: "Provider fireworks is in cooldown (all profiles unavailable)",
reason: "rate_limit",
},
],
}),
);
const { run } = createMinimalRun({
resolvedVerboseLevel: "off",
});
const phases: string[] = [];
const off = onAgentEvent((evt) => {
const phase = typeof evt.data?.phase === "string" ? evt.data.phase : null;
if (evt.stream === "lifecycle" && phase) {
phases.push(phase);
}
});
const res = await run();
off();
const payload = Array.isArray(res) ? (res[0] as { text?: string }) : (res as { text?: string });
expect(payload.text).not.toContain("Model Fallback:");
expect(phases.filter((phase) => phase === "fallback")).toHaveLength(1);
});
it("announces model fallback only once per active fallback state", async () => {
const { onAgentEvent } = await import("../../infra/agent-events.js");
const sessionEntry: SessionEntry = {
sessionId: "session",
updatedAt: Date.now(),
};
const sessionStore = { main: sessionEntry };
state.runEmbeddedPiAgentMock.mockResolvedValue({
payloads: [{ text: "final" }],
meta: {},
});
const modelFallback = await import("../../agents/model-fallback.js");
const fallbackSpy = vi
.spyOn(modelFallback, "runWithModelFallback")
.mockImplementation(
async ({ run }: { run: (provider: string, model: string) => Promise<unknown> }) => ({
result: await run("deepinfra", "moonshotai/Kimi-K2.5"),
provider: "deepinfra",
model: "moonshotai/Kimi-K2.5",
attempts: [
{
provider: "fireworks",
model: "fireworks/minimax-m2p5",
error: "Provider fireworks is in cooldown (all profiles unavailable)",
reason: "rate_limit",
},
],
}),
);
try {
const { run } = createMinimalRun({
resolvedVerboseLevel: "on",
sessionEntry,
sessionStore,
sessionKey: "main",
});
const fallbackEvents: Array<Record<string, unknown>> = [];
const off = onAgentEvent((evt) => {
if (evt.stream === "lifecycle" && evt.data?.phase === "fallback") {
fallbackEvents.push(evt.data);
}
});
const first = await run();
const second = await run();
off();
const firstText = Array.isArray(first) ? first[0]?.text : first?.text;
const secondText = Array.isArray(second) ? second[0]?.text : second?.text;
expect(firstText).toContain("Model Fallback:");
expect(secondText).not.toContain("Model Fallback:");
expect(fallbackEvents).toHaveLength(1);
} finally {
fallbackSpy.mockRestore();
}
});
it("re-announces model fallback after returning to selected model", async () => {
const sessionEntry: SessionEntry = {
sessionId: "session",
updatedAt: Date.now(),
};
const sessionStore = { main: sessionEntry };
let callCount = 0;
state.runEmbeddedPiAgentMock.mockResolvedValue({
payloads: [{ text: "final" }],
meta: {},
});
const modelFallback = await import("../../agents/model-fallback.js");
const fallbackSpy = vi
.spyOn(modelFallback, "runWithModelFallback")
.mockImplementation(
async ({
provider,
model,
run,
}: {
provider: string;
model: string;
run: (provider: string, model: string) => Promise<unknown>;
}) => {
callCount += 1;
if (callCount === 2) {
return {
result: await run(provider, model),
provider,
model,
attempts: [],
};
}
return {
result: await run("deepinfra", "moonshotai/Kimi-K2.5"),
provider: "deepinfra",
model: "moonshotai/Kimi-K2.5",
attempts: [
{
provider: "fireworks",
model: "fireworks/minimax-m2p5",
error: "Provider fireworks is in cooldown (all profiles unavailable)",
reason: "rate_limit",
},
],
};
},
);
try {
const { run } = createMinimalRun({
resolvedVerboseLevel: "on",
sessionEntry,
sessionStore,
sessionKey: "main",
});
const first = await run();
const second = await run();
const third = await run();
const firstText = Array.isArray(first) ? first[0]?.text : first?.text;
const secondText = Array.isArray(second) ? second[0]?.text : second?.text;
const thirdText = Array.isArray(third) ? third[0]?.text : third?.text;
expect(firstText).toContain("Model Fallback:");
expect(secondText).not.toContain("Model Fallback:");
expect(thirdText).toContain("Model Fallback:");
} finally {
fallbackSpy.mockRestore();
}
});
it("announces fallback-cleared once when runtime returns to selected model", async () => {
const { onAgentEvent } = await import("../../infra/agent-events.js");
const sessionEntry: SessionEntry = {
sessionId: "session",
updatedAt: Date.now(),
};
const sessionStore = { main: sessionEntry };
let callCount = 0;
state.runEmbeddedPiAgentMock.mockResolvedValue({
payloads: [{ text: "final" }],
meta: {},
});
const modelFallback = await import("../../agents/model-fallback.js");
const fallbackSpy = vi
.spyOn(modelFallback, "runWithModelFallback")
.mockImplementation(
async ({
provider,
model,
run,
}: {
provider: string;
model: string;
run: (provider: string, model: string) => Promise<unknown>;
}) => {
callCount += 1;
if (callCount === 1) {
return {
result: await run("deepinfra", "moonshotai/Kimi-K2.5"),
provider: "deepinfra",
model: "moonshotai/Kimi-K2.5",
attempts: [
{
provider: "fireworks",
model: "fireworks/minimax-m2p5",
error: "Provider fireworks is in cooldown (all profiles unavailable)",
reason: "rate_limit",
},
],
};
}
return {
result: await run(provider, model),
provider,
model,
attempts: [],
};
},
);
try {
const { run } = createMinimalRun({
resolvedVerboseLevel: "on",
sessionEntry,
sessionStore,
sessionKey: "main",
});
const phases: string[] = [];
const off = onAgentEvent((evt) => {
const phase = typeof evt.data?.phase === "string" ? evt.data.phase : null;
if (evt.stream === "lifecycle" && phase) {
phases.push(phase);
}
});
const first = await run();
const second = await run();
const third = await run();
off();
const firstText = Array.isArray(first) ? first[0]?.text : first?.text;
const secondText = Array.isArray(second) ? second[0]?.text : second?.text;
const thirdText = Array.isArray(third) ? third[0]?.text : third?.text;
expect(firstText).toContain("Model Fallback:");
expect(secondText).toContain("Model Fallback cleared:");
expect(thirdText).not.toContain("Model Fallback cleared:");
expect(phases.filter((phase) => phase === "fallback")).toHaveLength(1);
expect(phases.filter((phase) => phase === "fallback_cleared")).toHaveLength(1);
} finally {
fallbackSpy.mockRestore();
}
});
it("emits fallback lifecycle events while verbose is off", async () => {
const { onAgentEvent } = await import("../../infra/agent-events.js");
const sessionEntry: SessionEntry = {
sessionId: "session",
updatedAt: Date.now(),
};
const sessionStore = { main: sessionEntry };
let callCount = 0;
state.runEmbeddedPiAgentMock.mockResolvedValue({
payloads: [{ text: "final" }],
meta: {},
});
const modelFallback = await import("../../agents/model-fallback.js");
const fallbackSpy = vi
.spyOn(modelFallback, "runWithModelFallback")
.mockImplementation(
async ({
provider,
model,
run,
}: {
provider: string;
model: string;
run: (provider: string, model: string) => Promise<unknown>;
}) => {
callCount += 1;
if (callCount === 1) {
return {
result: await run("deepinfra", "moonshotai/Kimi-K2.5"),
provider: "deepinfra",
model: "moonshotai/Kimi-K2.5",
attempts: [
{
provider: "fireworks",
model: "fireworks/minimax-m2p5",
error: "Provider fireworks is in cooldown (all profiles unavailable)",
reason: "rate_limit",
},
],
};
}
return {
result: await run(provider, model),
provider,
model,
attempts: [],
};
},
);
try {
const { run } = createMinimalRun({
resolvedVerboseLevel: "off",
sessionEntry,
sessionStore,
sessionKey: "main",
});
const phases: string[] = [];
const off = onAgentEvent((evt) => {
const phase = typeof evt.data?.phase === "string" ? evt.data.phase : null;
if (evt.stream === "lifecycle" && phase) {
phases.push(phase);
}
});
const first = await run();
const second = await run();
off();
const firstText = Array.isArray(first) ? first[0]?.text : first?.text;
const secondText = Array.isArray(second) ? second[0]?.text : second?.text;
expect(firstText).not.toContain("Model Fallback:");
expect(secondText).not.toContain("Model Fallback cleared:");
expect(phases.filter((phase) => phase === "fallback")).toHaveLength(1);
expect(phases.filter((phase) => phase === "fallback_cleared")).toHaveLength(1);
} finally {
fallbackSpy.mockRestore();
}
});
it("backfills fallback reason when fallback is already active", async () => {
const sessionEntry: SessionEntry = {
sessionId: "session",
updatedAt: Date.now(),
fallbackNoticeSelectedModel: "anthropic/claude",
fallbackNoticeActiveModel: "deepinfra/moonshotai/Kimi-K2.5",
modelProvider: "deepinfra",
model: "moonshotai/Kimi-K2.5",
};
const sessionStore = { main: sessionEntry };
state.runEmbeddedPiAgentMock.mockResolvedValue({
payloads: [{ text: "final" }],
meta: {},
});
const modelFallback = await import("../../agents/model-fallback.js");
const fallbackSpy = vi
.spyOn(modelFallback, "runWithModelFallback")
.mockImplementation(
async ({ run }: { run: (provider: string, model: string) => Promise<unknown> }) => ({
result: await run("deepinfra", "moonshotai/Kimi-K2.5"),
provider: "deepinfra",
model: "moonshotai/Kimi-K2.5",
attempts: [
{
provider: "anthropic",
model: "claude",
error: "Provider anthropic is in cooldown (all profiles unavailable)",
reason: "rate_limit",
},
],
}),
);
try {
const { run } = createMinimalRun({
resolvedVerboseLevel: "on",
sessionEntry,
sessionStore,
sessionKey: "main",
});
const res = await run();
const firstText = Array.isArray(res) ? res[0]?.text : res?.text;
expect(firstText).not.toContain("Model Fallback:");
expect(sessionEntry.fallbackNoticeReason).toBe("rate limit");
} finally {
fallbackSpy.mockRestore();
}
});
it("refreshes fallback reason summary while fallback stays active", async () => {
const sessionEntry: SessionEntry = {
sessionId: "session",
updatedAt: Date.now(),
fallbackNoticeSelectedModel: "anthropic/claude",
fallbackNoticeActiveModel: "deepinfra/moonshotai/Kimi-K2.5",
fallbackNoticeReason: "rate limit",
modelProvider: "deepinfra",
model: "moonshotai/Kimi-K2.5",
};
const sessionStore = { main: sessionEntry };
state.runEmbeddedPiAgentMock.mockResolvedValue({
payloads: [{ text: "final" }],
meta: {},
});
const modelFallback = await import("../../agents/model-fallback.js");
const fallbackSpy = vi
.spyOn(modelFallback, "runWithModelFallback")
.mockImplementation(
async ({ run }: { run: (provider: string, model: string) => Promise<unknown> }) => ({
result: await run("deepinfra", "moonshotai/Kimi-K2.5"),
provider: "deepinfra",
model: "moonshotai/Kimi-K2.5",
attempts: [
{
provider: "anthropic",
model: "claude",
error: "Provider anthropic is in cooldown (all profiles unavailable)",
reason: "timeout",
},
],
}),
);
try {
const { run } = createMinimalRun({
resolvedVerboseLevel: "on",
sessionEntry,
sessionStore,
sessionKey: "main",
});
const res = await run();
const firstText = Array.isArray(res) ? res[0]?.text : res?.text;
expect(firstText).not.toContain("Model Fallback:");
expect(sessionEntry.fallbackNoticeReason).toBe("timeout");
} finally {
fallbackSpy.mockRestore();
}
});
it("retries after compaction failure by resetting the session", async () => {
await withTempStateDir(async (stateDir) => {
const sessionId = "session";
const storePath = path.join(stateDir, "sessions", "sessions.json");
const transcriptPath = sessions.resolveSessionTranscriptPath(sessionId);
const sessionEntry = { sessionId, updatedAt: Date.now(), sessionFile: transcriptPath };
const sessionEntry = {
sessionId,
updatedAt: Date.now(),
sessionFile: transcriptPath,
fallbackNoticeSelectedModel: "fireworks/minimax-m2p5",
fallbackNoticeActiveModel: "deepinfra/moonshotai/Kimi-K2.5",
fallbackNoticeReason: "rate limit",
};
const sessionStore = { main: sessionEntry };
await fs.mkdir(path.dirname(storePath), { recursive: true });
@@ -575,9 +1070,15 @@ describe("runReplyAgent typing (heartbeat)", () => {
}
expect(payload.text?.toLowerCase()).toContain("reset");
expect(sessionStore.main.sessionId).not.toBe(sessionId);
expect(sessionStore.main.fallbackNoticeSelectedModel).toBeUndefined();
expect(sessionStore.main.fallbackNoticeActiveModel).toBeUndefined();
expect(sessionStore.main.fallbackNoticeReason).toBeUndefined();
const persisted = JSON.parse(await fs.readFile(storePath, "utf-8"));
expect(persisted.main.sessionId).toBe(sessionStore.main.sessionId);
expect(persisted.main.fallbackNoticeSelectedModel).toBeUndefined();
expect(persisted.main.fallbackNoticeActiveModel).toBeUndefined();
expect(persisted.main.fallbackNoticeReason).toBeUndefined();
});
});

View File

@@ -15,10 +15,16 @@ import {
updateSessionStoreEntry,
} from "../../config/sessions.js";
import type { TypingMode } from "../../config/types.js";
import { emitAgentEvent } from "../../infra/agent-events.js";
import { emitDiagnosticEvent, isDiagnosticsEnabled } from "../../infra/diagnostic-events.js";
import { enqueueSystemEvent } from "../../infra/system-events.js";
import { defaultRuntime } from "../../runtime.js";
import { estimateUsageCost, resolveModelCostConfig } from "../../utils/usage-format.js";
import {
buildFallbackClearedNotice,
buildFallbackNotice,
resolveFallbackTransition,
} from "../fallback-state.js";
import type { OriginatingChannelType, TemplateContext } from "../templating.js";
import { resolveResponseUsageMode, type VerboseLevel } from "../thinking.js";
import type { GetReplyOptions, ReplyPayload } from "../types.js";
@@ -290,6 +296,9 @@ export async function runReplyAgent(params: {
updatedAt: Date.now(),
systemSent: false,
abortedLastRun: false,
fallbackNoticeSelectedModel: undefined,
fallbackNoticeActiveModel: undefined,
fallbackNoticeReason: undefined,
};
const agentId = resolveAgentIdFromSessionKey(sessionKey);
const nextSessionFile = resolveSessionTranscriptPath(
@@ -373,7 +382,14 @@ export async function runReplyAgent(params: {
return finalizeWithFollowup(runOutcome.payload, queueKey, runFollowupTurn);
}
const { runResult, fallbackProvider, fallbackModel, directlySentBlockKeys } = runOutcome;
const {
runId,
runResult,
fallbackProvider,
fallbackModel,
fallbackAttempts,
directlySentBlockKeys,
} = runOutcome;
let { didLogHeartbeatStrip, autoCompactionCompleted } = runOutcome;
if (
@@ -414,6 +430,42 @@ export async function runReplyAgent(params: {
const modelUsed = runResult.meta?.agentMeta?.model ?? fallbackModel ?? defaultModel;
const providerUsed =
runResult.meta?.agentMeta?.provider ?? fallbackProvider ?? followupRun.run.provider;
const verboseEnabled = resolvedVerboseLevel !== "off";
const selectedProvider = followupRun.run.provider;
const selectedModel = followupRun.run.model;
const fallbackStateEntry =
activeSessionEntry ?? (sessionKey ? activeSessionStore?.[sessionKey] : undefined);
const fallbackTransition = resolveFallbackTransition({
selectedProvider,
selectedModel,
activeProvider: providerUsed,
activeModel: modelUsed,
attempts: fallbackAttempts,
state: fallbackStateEntry,
});
if (fallbackTransition.stateChanged) {
if (fallbackStateEntry) {
fallbackStateEntry.fallbackNoticeSelectedModel = fallbackTransition.nextState.selectedModel;
fallbackStateEntry.fallbackNoticeActiveModel = fallbackTransition.nextState.activeModel;
fallbackStateEntry.fallbackNoticeReason = fallbackTransition.nextState.reason;
fallbackStateEntry.updatedAt = Date.now();
activeSessionEntry = fallbackStateEntry;
}
if (sessionKey && fallbackStateEntry && activeSessionStore) {
activeSessionStore[sessionKey] = fallbackStateEntry;
}
if (sessionKey && storePath) {
await updateSessionStoreEntry({
storePath,
sessionKey,
update: async () => ({
fallbackNoticeSelectedModel: fallbackTransition.nextState.selectedModel,
fallbackNoticeActiveModel: fallbackTransition.nextState.activeModel,
fallbackNoticeReason: fallbackTransition.nextState.reason,
}),
});
}
}
const cliSessionId = isCliProvider(providerUsed, cfg)
? runResult.meta?.agentMeta?.sessionId?.trim()
: undefined;
@@ -546,9 +598,68 @@ export async function runReplyAgent(params: {
}
}
// If verbose is enabled and this is a new session, prepend a session hint.
// If verbose is enabled, prepend operational run notices.
let finalPayloads = guardedReplyPayloads;
const verboseEnabled = resolvedVerboseLevel !== "off";
const verboseNotices: ReplyPayload[] = [];
if (verboseEnabled && activeIsNewSession) {
verboseNotices.push({ text: `🧭 New session: ${followupRun.run.sessionId}` });
}
if (fallbackTransition.fallbackTransitioned) {
emitAgentEvent({
runId,
sessionKey,
stream: "lifecycle",
data: {
phase: "fallback",
selectedProvider,
selectedModel,
activeProvider: providerUsed,
activeModel: modelUsed,
reasonSummary: fallbackTransition.reasonSummary,
attemptSummaries: fallbackTransition.attemptSummaries,
attempts: fallbackAttempts,
},
});
if (verboseEnabled) {
const fallbackNotice = buildFallbackNotice({
selectedProvider,
selectedModel,
activeProvider: providerUsed,
activeModel: modelUsed,
attempts: fallbackAttempts,
});
if (fallbackNotice) {
verboseNotices.push({ text: fallbackNotice });
}
}
}
if (fallbackTransition.fallbackCleared) {
emitAgentEvent({
runId,
sessionKey,
stream: "lifecycle",
data: {
phase: "fallback_cleared",
selectedProvider,
selectedModel,
activeProvider: providerUsed,
activeModel: modelUsed,
previousActiveModel: fallbackTransition.previousState.activeModel,
},
});
if (verboseEnabled) {
verboseNotices.push({
text: buildFallbackClearedNotice({
selectedProvider,
selectedModel,
previousActiveModel: fallbackTransition.previousState.activeModel,
}),
});
}
}
if (autoCompactionCompleted) {
const count = await incrementRunCompactionCount({
sessionEntry: activeSessionEntry,
@@ -578,11 +689,11 @@ export async function runReplyAgent(params: {
if (verboseEnabled) {
const suffix = typeof count === "number" ? ` (count ${count})` : "";
finalPayloads = [{ text: `🧹 Auto-compaction complete${suffix}.` }, ...finalPayloads];
verboseNotices.push({ text: `🧹 Auto-compaction complete${suffix}.` });
}
}
if (verboseEnabled && activeIsNewSession) {
finalPayloads = [{ text: `🧭 New session: ${followupRun.run.sessionId}` }, ...finalPayloads];
if (verboseNotices.length > 0) {
finalPayloads = [...verboseNotices, ...finalPayloads];
}
if (responseUsageLine) {
finalPayloads = appendUsageLine(finalPayloads, responseUsageLine);

View File

@@ -19,6 +19,7 @@ import {
} from "../../infra/provider-usage.js";
import type { MediaUnderstandingDecision } from "../../media-understanding/types.js";
import { normalizeGroupActivation } from "../group-activation.js";
import { resolveSelectedAndActiveModel } from "../model-runtime.js";
import { buildStatusMessage } from "../status.js";
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "../thinking.js";
import type { ReplyPayload } from "../types.js";
@@ -136,6 +137,25 @@ export async function buildStatusReply(params: {
const groupActivation = isGroup
? (normalizeGroupActivation(sessionEntry?.groupActivation) ?? defaultGroupActivation())
: undefined;
const modelRefs = resolveSelectedAndActiveModel({
selectedProvider: provider,
selectedModel: model,
sessionEntry,
});
const selectedModelAuth = resolveModelAuthLabel({
provider,
cfg,
sessionEntry,
agentDir: statusAgentDir,
});
const activeModelAuth = modelRefs.activeDiffers
? resolveModelAuthLabel({
provider: modelRefs.active.provider,
cfg,
sessionEntry,
agentDir: statusAgentDir,
})
: selectedModelAuth;
const agentDefaults = cfg.agents?.defaults ?? {};
const statusText = buildStatusMessage({
config: cfg,
@@ -160,12 +180,8 @@ export async function buildStatusReply(params: {
resolvedVerbose: resolvedVerboseLevel,
resolvedReasoning: resolvedReasoningLevel,
resolvedElevated: resolvedElevatedLevel,
modelAuth: resolveModelAuthLabel({
provider,
cfg,
sessionEntry,
agentDir: statusAgentDir,
}),
modelAuth: selectedModelAuth,
activeModelAuth,
usageLine: usageLine ?? undefined,
queue: {
mode: queueSettings.mode,

View File

@@ -106,6 +106,7 @@ export async function handleDirectiveOnly(
allowedModelCatalog,
resetModelOverride,
surface: params.surface,
sessionEntry,
});
if (modelInfo) {
return modelInfo;

View File

@@ -63,6 +63,32 @@ describe("/model chat UX", () => {
expect(reply?.text).toContain("Switch: /model <provider/model>");
});
it("shows active runtime model when different from selected model", async () => {
const directives = parseInlineDirectives("/model");
const cfg = { commands: { text: true } } as unknown as OpenClawConfig;
const reply = await maybeHandleModelDirectiveInfo({
directives,
cfg,
agentDir: "/tmp/agent",
activeAgentId: "main",
provider: "fireworks",
model: "fireworks/minimax-m2p5",
defaultProvider: "fireworks",
defaultModel: "fireworks/minimax-m2p5",
aliasIndex: baseAliasIndex(),
allowedModelCatalog: [],
resetModelOverride: false,
sessionEntry: {
modelProvider: "deepinfra",
model: "moonshotai/Kimi-K2.5",
},
});
expect(reply?.text).toContain("Current: fireworks/minimax-m2p5 (selected)");
expect(reply?.text).toContain("Active: deepinfra/moonshotai/Kimi-K2.5 (runtime)");
});
it("auto-applies closest match for typos", () => {
const directives = parseInlineDirectives("/model anthropic/claud-opus-4-5");
const cfg = { commands: { text: true } } as unknown as OpenClawConfig;

View File

@@ -7,8 +7,10 @@ import {
resolveModelRefFromString,
} from "../../agents/model-selection.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { SessionEntry } from "../../config/sessions.js";
import { buildBrowseProvidersButton } from "../../telegram/model-buttons.js";
import { shortenHomePath } from "../../utils.js";
import { resolveSelectedAndActiveModel } from "../model-runtime.js";
import type { ReplyPayload } from "../types.js";
import { resolveModelsCommandReply } from "./commands-models.js";
import {
@@ -198,6 +200,7 @@ export async function maybeHandleModelDirectiveInfo(params: {
allowedModelCatalog: Array<{ provider: string; id?: string; name?: string }>;
resetModelOverride: boolean;
surface?: string;
sessionEntry?: Pick<SessionEntry, "modelProvider" | "model">;
}): Promise<ReplyPayload | undefined> {
if (!params.directives.hasModelDirective) {
return undefined;
@@ -233,31 +236,45 @@ export async function maybeHandleModelDirectiveInfo(params: {
}
if (wantsSummary) {
const current = `${params.provider}/${params.model}`;
const modelRefs = resolveSelectedAndActiveModel({
selectedProvider: params.provider,
selectedModel: params.model,
sessionEntry: params.sessionEntry,
});
const current = modelRefs.selected.label;
const isTelegram = params.surface === "telegram";
const activeRuntimeLine = modelRefs.activeDiffers
? `Active: ${modelRefs.active.label} (runtime)`
: null;
if (isTelegram) {
const buttons = buildBrowseProvidersButton();
return {
text: [
`Current: ${current}`,
`Current: ${current}${modelRefs.activeDiffers ? " (selected)" : ""}`,
activeRuntimeLine,
"",
"Tap below to browse models, or use:",
"/model <provider/model> to switch",
"/model status for details",
].join("\n"),
]
.filter(Boolean)
.join("\n"),
channelData: { telegram: { buttons } },
};
}
return {
text: [
`Current: ${current}`,
`Current: ${current}${modelRefs.activeDiffers ? " (selected)" : ""}`,
activeRuntimeLine,
"",
"Switch: /model <provider/model>",
"Browse: /models (providers) or /models <provider> (models)",
"More: /model status",
].join("\n"),
]
.filter(Boolean)
.join("\n"),
};
}
@@ -284,14 +301,20 @@ export async function maybeHandleModelDirectiveInfo(params: {
authByProvider.set(provider, formatAuthLabel(auth));
}
const current = `${params.provider}/${params.model}`;
const modelRefs = resolveSelectedAndActiveModel({
selectedProvider: params.provider,
selectedModel: params.model,
sessionEntry: params.sessionEntry,
});
const current = modelRefs.selected.label;
const defaultLabel = `${params.defaultProvider}/${params.defaultModel}`;
const lines = [
`Current: ${current}`,
`Current: ${current}${modelRefs.activeDiffers ? " (selected)" : ""}`,
modelRefs.activeDiffers ? `Active: ${modelRefs.active.label} (runtime)` : null,
`Default: ${defaultLabel}`,
`Agent: ${params.activeAgentId}`,
`Auth file: ${formatPath(resolveAuthStorePathForDisplay(params.agentDir))}`,
];
].filter((line): line is string => Boolean(line));
if (params.resetModelOverride) {
lines.push(`(previous selection reset to default)`);
}

View File

@@ -190,7 +190,7 @@ describe("buildStatusMessage", () => {
expect(optionsLine).not.toContain("elevated");
});
it("prefers model overrides over last-run model", () => {
it("shows selected model and active runtime model when they differ", () => {
const text = buildStatusMessage({
agent: {
model: "anthropic/claude-opus-4-5",
@@ -203,15 +203,76 @@ describe("buildStatusMessage", () => {
modelOverride: "gpt-4.1-mini",
modelProvider: "anthropic",
model: "claude-haiku-4-5",
fallbackNoticeSelectedModel: "openai/gpt-4.1-mini",
fallbackNoticeActiveModel: "anthropic/claude-haiku-4-5",
fallbackNoticeReason: "rate limit",
contextTokens: 32_000,
},
sessionKey: "agent:main:main",
sessionScope: "per-sender",
queue: { mode: "collect", depth: 0 },
modelAuth: "api-key",
activeModelAuth: "api-key di_123…abc (deepinfra:default)",
});
const normalized = normalizeTestText(text);
expect(normalized).toContain("Model: openai/gpt-4.1-mini");
expect(normalized).toContain("Fallback: anthropic/claude-haiku-4-5");
expect(normalized).toContain("(rate limit)");
expect(normalized).not.toContain(" - Reason:");
expect(normalized).not.toContain("Active:");
expect(normalized).toContain("di_123...abc");
});
it("omits active fallback details when runtime drift does not match fallback state", () => {
const text = buildStatusMessage({
agent: {
model: "openai/gpt-4.1-mini",
contextTokens: 32_000,
},
sessionEntry: {
sessionId: "runtime-drift-only",
updatedAt: 0,
modelProvider: "anthropic",
model: "claude-haiku-4-5",
fallbackNoticeSelectedModel: "fireworks/minimax-m2p5",
fallbackNoticeActiveModel: "deepinfra/moonshotai/Kimi-K2.5",
fallbackNoticeReason: "rate limit",
},
sessionKey: "agent:main:main",
sessionScope: "per-sender",
queue: { mode: "collect", depth: 0 },
modelAuth: "api-key",
activeModelAuth: "api-key di_123…abc (deepinfra:default)",
});
const normalized = normalizeTestText(text);
expect(normalized).toContain("Model: openai/gpt-4.1-mini");
expect(normalized).not.toContain("Fallback:");
expect(normalized).not.toContain("(rate limit)");
});
it("omits active lines when runtime matches selected model", () => {
const text = buildStatusMessage({
agent: {
model: "openai/gpt-4.1-mini",
contextTokens: 32_000,
},
sessionEntry: {
sessionId: "selected-active-same",
updatedAt: 0,
modelProvider: "openai",
model: "gpt-4.1-mini",
fallbackNoticeReason: "unknown",
},
sessionKey: "agent:main:main",
sessionScope: "per-sender",
queue: { mode: "collect", depth: 0 },
modelAuth: "api-key",
});
expect(normalizeTestText(text)).toContain("Model: openai/gpt-4.1-mini");
const normalized = normalizeTestText(text);
expect(normalized).not.toContain("Fallback:");
});
it("keeps provider prefix from configured model", () => {

View File

@@ -40,6 +40,8 @@ import {
type ChatCommandDefinition,
} from "./commands-registry.js";
import type { CommandCategory } from "./commands-registry.types.js";
import { resolveActiveFallbackState } from "./fallback-state.js";
import { formatProviderModelRef, resolveSelectedAndActiveModel } from "./model-runtime.js";
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "./thinking.js";
type AgentDefaults = NonNullable<NonNullable<OpenClawConfig["agents"]>["defaults"]>;
@@ -72,6 +74,7 @@ type StatusArgs = {
resolvedReasoning?: ReasoningLevel;
resolvedElevated?: ElevatedLevel;
modelAuth?: string;
activeModelAuth?: string;
usageLine?: string;
timeLine?: string;
queue?: QueueStatus;
@@ -339,12 +342,19 @@ export function buildStatusMessage(args: StatusArgs): string {
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
const provider = entry?.providerOverride ?? resolved.provider ?? DEFAULT_PROVIDER;
let model = entry?.modelOverride ?? resolved.model ?? DEFAULT_MODEL;
const selectedProvider = entry?.providerOverride ?? resolved.provider ?? DEFAULT_PROVIDER;
const selectedModel = entry?.modelOverride ?? resolved.model ?? DEFAULT_MODEL;
const modelRefs = resolveSelectedAndActiveModel({
selectedProvider,
selectedModel,
sessionEntry: entry,
});
let activeProvider = modelRefs.active.provider;
let activeModel = modelRefs.active.model;
let contextTokens =
entry?.contextTokens ??
args.agent?.contextTokens ??
lookupContextTokens(model) ??
lookupContextTokens(activeModel) ??
DEFAULT_CONTEXT_TOKENS;
let inputTokens = entry?.inputTokens;
@@ -366,8 +376,18 @@ export function buildStatusMessage(args: StatusArgs): string {
if (!totalTokens || totalTokens === 0 || candidate > totalTokens) {
totalTokens = candidate;
}
if (!model) {
model = logUsage.model ?? model;
if (!entry?.model && logUsage.model) {
const slashIndex = logUsage.model.indexOf("/");
if (slashIndex > 0) {
const provider = logUsage.model.slice(0, slashIndex).trim();
const model = logUsage.model.slice(slashIndex + 1).trim();
if (provider && model) {
activeProvider = provider;
activeModel = model;
}
} else {
activeModel = logUsage.model;
}
}
if (!contextTokens && logUsage.model) {
contextTokens = lookupContextTokens(logUsage.model) ?? contextTokens;
@@ -440,14 +460,21 @@ export function buildStatusMessage(args: StatusArgs): string {
];
const activationLine = activationParts.filter(Boolean).join(" · ");
const authMode = resolveModelAuthMode(provider, args.config);
const authLabelValue =
args.modelAuth ?? (authMode && authMode !== "unknown" ? authMode : undefined);
const showCost = authLabelValue === "api-key" || authLabelValue === "mixed";
const activeAuthMode = resolveModelAuthMode(activeProvider, args.config);
const selectedAuthLabelValue =
args.modelAuth ??
(() => {
const selectedAuthMode = resolveModelAuthMode(selectedProvider, args.config);
return selectedAuthMode && selectedAuthMode !== "unknown" ? selectedAuthMode : undefined;
})();
const activeAuthLabelValue =
args.activeModelAuth ??
(activeAuthMode && activeAuthMode !== "unknown" ? activeAuthMode : undefined);
const showCost = activeAuthLabelValue === "api-key" || activeAuthLabelValue === "mixed";
const costConfig = showCost
? resolveModelCostConfig({
provider,
model,
provider: activeProvider,
model: activeModel,
config: args.config,
})
: undefined;
@@ -464,9 +491,21 @@ export function buildStatusMessage(args: StatusArgs): string {
: undefined;
const costLabel = showCost && hasUsage ? formatUsd(cost) : undefined;
const modelLabel = model ? `${provider}/${model}` : "unknown";
const authLabel = authLabelValue ? ` · 🔑 ${authLabelValue}` : "";
const modelLine = `🧠 Model: ${modelLabel}${authLabel}`;
const selectedModelLabel = modelRefs.selected.label || "unknown";
const activeModelLabel = formatProviderModelRef(activeProvider, activeModel) || "unknown";
const fallbackState = resolveActiveFallbackState({
selectedModelRef: selectedModelLabel,
activeModelRef: activeModelLabel,
state: entry,
});
const selectedAuthLabel = selectedAuthLabelValue ? ` · 🔑 ${selectedAuthLabelValue}` : "";
const modelLine = `🧠 Model: ${selectedModelLabel}${selectedAuthLabel}`;
const showFallbackAuth = activeAuthLabelValue && activeAuthLabelValue !== selectedAuthLabelValue;
const fallbackLine = fallbackState.active
? `↪️ Fallback: ${activeModelLabel}${
showFallbackAuth ? ` · 🔑 ${activeAuthLabelValue}` : ""
} (${fallbackState.reason ?? "selected model unavailable"})`
: null;
const commit = resolveCommitHash();
const versionLine = `🦞 OpenClaw ${VERSION}${commit ? ` (${commit})` : ""}`;
const usagePair = formatUsagePair(inputTokens, outputTokens);
@@ -480,6 +519,7 @@ export function buildStatusMessage(args: StatusArgs): string {
versionLine,
args.timeLine,
modelLine,
fallbackLine,
usageCostLine,
`📚 ${contextLine}`,
mediaLine,

View File

@@ -482,6 +482,7 @@ describe("update-cli", () => {
force: true,
json: undefined,
});
expect(runRestartScript).toHaveBeenCalled();
expect(runDaemonRestart).not.toHaveBeenCalled();
});
@@ -508,6 +509,29 @@ describe("update-cli", () => {
expect(runDaemonRestart).toHaveBeenCalled();
});
it("updateCommand falls back to restart when no detached restart script is available", async () => {
const mockResult: UpdateRunResult = {
status: "ok",
mode: "git",
steps: [],
durationMs: 100,
};
vi.mocked(runGatewayUpdate).mockResolvedValue(mockResult);
vi.mocked(runDaemonInstall).mockResolvedValue(undefined);
prepareRestartScript.mockResolvedValue(null);
serviceLoaded.mockResolvedValue(true);
vi.mocked(runDaemonRestart).mockResolvedValue(true);
await updateCommand({});
expect(runDaemonInstall).toHaveBeenCalledWith({
force: true,
json: undefined,
});
expect(runDaemonRestart).toHaveBeenCalled();
});
it("updateCommand does not refresh service env when --no-restart is set", async () => {
vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult());
serviceLoaded.mockResolvedValue(true);

View File

@@ -403,12 +403,9 @@ async function maybeRestartService(params: {
try {
let restarted = false;
let restartInitiated = false;
let serviceRefreshed = false;
if (params.refreshServiceEnv) {
try {
await runDaemonInstall({ force: true, json: params.opts.json });
serviceRefreshed = true;
restarted = true;
} catch (err) {
if (!params.opts.json) {
defaultRuntime.log(
@@ -419,13 +416,11 @@ async function maybeRestartService(params: {
}
}
}
if (!serviceRefreshed && params.restartScriptPath) {
if (params.restartScriptPath) {
await runRestartScript(params.restartScriptPath);
restartInitiated = true;
} else {
if (!serviceRefreshed) {
restarted = await runDaemonRestart();
}
restarted = await runDaemonRestart();
}
if (!params.opts.json && restarted) {

View File

@@ -313,8 +313,14 @@ async function requestAnthropicVerification(params: {
apiKey: string;
modelId: string;
}): Promise<VerificationResult> {
// Use a base URL with /v1 injected for this raw fetch only. The rest of the app uses the
// Anthropic client, which appends /v1 itself; config should store the base URL
// without /v1 to avoid /v1/v1/messages at runtime. See docs/gateway/configuration-reference.md.
const baseUrlForRequest = /\/v1\/?$/.test(params.baseUrl.trim())
? params.baseUrl.trim()
: params.baseUrl.trim().replace(/\/?$/, "") + "/v1";
const endpoint = resolveVerificationEndpoint({
baseUrl: params.baseUrl,
baseUrl: baseUrlForRequest,
modelId: params.modelId,
endpointPath: "messages",
});

View File

@@ -80,6 +80,13 @@ export type SessionEntry = {
totalTokensFresh?: boolean;
modelProvider?: string;
model?: string;
/**
* Last selected/runtime model pair for which a fallback notice was emitted.
* Used to avoid repeating the same fallback notice every turn.
*/
fallbackNoticeSelectedModel?: string;
fallbackNoticeActiveModel?: string;
fallbackNoticeReason?: string;
contextTokens?: number;
compactionCount?: number;
memoryFlushAt?: number;

View File

@@ -256,4 +256,141 @@ describe("agent event handler", () => {
expect(payload.data?.result).toEqual(result);
resetAgentRunContextForTest();
});
it("broadcasts fallback events to agent subscribers and node session", () => {
const { broadcast, broadcastToConnIds, nodeSendToSession, handler } = createHarness({
resolveSessionKeyForRun: () => "session-fallback",
});
handler({
runId: "run-fallback",
seq: 1,
stream: "lifecycle",
ts: Date.now(),
data: {
phase: "fallback",
selectedProvider: "fireworks",
selectedModel: "fireworks/minimax-m2p5",
activeProvider: "deepinfra",
activeModel: "moonshotai/Kimi-K2.5",
},
});
expect(broadcastToConnIds).not.toHaveBeenCalled();
const broadcastAgentCalls = broadcast.mock.calls.filter(([event]) => event === "agent");
expect(broadcastAgentCalls).toHaveLength(1);
const payload = broadcastAgentCalls[0]?.[1] as {
sessionKey?: string;
stream?: string;
data?: Record<string, unknown>;
};
expect(payload.stream).toBe("lifecycle");
expect(payload.data?.phase).toBe("fallback");
expect(payload.sessionKey).toBe("session-fallback");
expect(payload.data?.activeProvider).toBe("deepinfra");
const nodeCalls = nodeSendToSession.mock.calls.filter(([, event]) => event === "agent");
expect(nodeCalls).toHaveLength(1);
});
it("remaps chat-linked lifecycle runId to client runId", () => {
const { broadcast, nodeSendToSession, chatRunState, handler } = createHarness({
resolveSessionKeyForRun: () => "session-fallback",
});
chatRunState.registry.add("run-fallback-internal", {
sessionKey: "session-fallback",
clientRunId: "run-fallback-client",
});
handler({
runId: "run-fallback-internal",
seq: 1,
stream: "lifecycle",
ts: Date.now(),
data: {
phase: "fallback",
selectedProvider: "fireworks",
selectedModel: "fireworks/minimax-m2p5",
activeProvider: "deepinfra",
activeModel: "moonshotai/Kimi-K2.5",
},
});
const broadcastAgentCalls = broadcast.mock.calls.filter(([event]) => event === "agent");
expect(broadcastAgentCalls).toHaveLength(1);
const payload = broadcastAgentCalls[0]?.[1] as {
runId?: string;
sessionKey?: string;
stream?: string;
data?: Record<string, unknown>;
};
expect(payload.runId).toBe("run-fallback-client");
expect(payload.stream).toBe("lifecycle");
expect(payload.data?.phase).toBe("fallback");
const nodeCalls = nodeSendToSession.mock.calls.filter(([, event]) => event === "agent");
expect(nodeCalls).toHaveLength(1);
const nodePayload = nodeCalls[0]?.[2] as { runId?: string };
expect(nodePayload.runId).toBe("run-fallback-client");
});
it("uses agent event sessionKey when run-context lookup cannot resolve", () => {
const { broadcast, handler } = createHarness({
resolveSessionKeyForRun: () => undefined,
});
handler({
runId: "run-fallback-session-key",
seq: 1,
stream: "lifecycle",
ts: Date.now(),
sessionKey: "session-from-event",
data: {
phase: "fallback",
selectedProvider: "fireworks",
selectedModel: "fireworks/minimax-m2p5",
activeProvider: "deepinfra",
activeModel: "moonshotai/Kimi-K2.5",
},
});
const broadcastAgentCalls = broadcast.mock.calls.filter(([event]) => event === "agent");
expect(broadcastAgentCalls).toHaveLength(1);
const payload = broadcastAgentCalls[0]?.[1] as { sessionKey?: string };
expect(payload.sessionKey).toBe("session-from-event");
});
it("remaps chat-linked tool runId for non-full verbose payloads", () => {
const { broadcastToConnIds, chatRunState, toolEventRecipients, handler } = createHarness({
resolveSessionKeyForRun: () => "session-tool-remap",
});
chatRunState.registry.add("run-tool-internal", {
sessionKey: "session-tool-remap",
clientRunId: "run-tool-client",
});
registerAgentRunContext("run-tool-internal", {
sessionKey: "session-tool-remap",
verboseLevel: "on",
});
toolEventRecipients.add("run-tool-internal", "conn-1");
handler({
runId: "run-tool-internal",
seq: 1,
stream: "tool",
ts: Date.now(),
data: {
phase: "result",
name: "exec",
toolCallId: "tool-remap-1",
result: { content: [{ type: "text", text: "secret" }] },
},
});
expect(broadcastToConnIds).toHaveBeenCalledTimes(1);
const payload = broadcastToConnIds.mock.calls[0]?.[1] as { runId?: string };
expect(payload.runId).toBe("run-tool-client");
resetAgentRunContextForTest();
});
});

View File

@@ -325,12 +325,17 @@ export function createAgentEventHandler({
return (evt: AgentEventPayload) => {
const chatLink = chatRunState.registry.peek(evt.runId);
const sessionKey = chatLink?.sessionKey ?? resolveSessionKeyForRun(evt.runId);
const eventSessionKey =
typeof evt.sessionKey === "string" && evt.sessionKey.trim() ? evt.sessionKey : undefined;
const sessionKey =
chatLink?.sessionKey ?? eventSessionKey ?? resolveSessionKeyForRun(evt.runId);
const clientRunId = chatLink?.clientRunId ?? evt.runId;
const eventRunId = chatLink?.clientRunId ?? evt.runId;
const eventForClients = chatLink ? { ...evt, runId: eventRunId } : evt;
const isAborted =
chatRunState.abortedRuns.has(clientRunId) || chatRunState.abortedRuns.has(evt.runId);
// Include sessionKey so Control UI can filter tool streams per session.
const agentPayload = sessionKey ? { ...evt, sessionKey } : evt;
const agentPayload = sessionKey ? { ...eventForClients, sessionKey } : eventForClients;
const last = agentRunSeq.get(evt.runId) ?? 0;
const isToolEvent = evt.stream === "tool";
const toolVerbose = isToolEvent ? resolveToolVerboseLevel(evt.runId, sessionKey) : "off";
@@ -341,12 +346,14 @@ export function createAgentEventHandler({
const data = evt.data ? { ...evt.data } : {};
delete data.result;
delete data.partialResult;
return sessionKey ? { ...evt, sessionKey, data } : { ...evt, data };
return sessionKey
? { ...eventForClients, sessionKey, data }
: { ...eventForClients, data };
})()
: agentPayload;
if (evt.seq !== last + 1) {
broadcast("agent", {
runId: evt.runId,
runId: eventRunId,
stream: "error",
ts: Date.now(),
sessionKey,
@@ -399,7 +406,7 @@ export function createAgentEventHandler({
} else {
emitChatFinal(
sessionKey,
evt.runId,
eventRunId,
evt.seq,
lifecyclePhase === "error" ? "error" : "done",
evt.data?.error,

View File

@@ -199,6 +199,8 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
channel: message.channel,
threadTs: streamThreadTs,
text,
teamId: ctx.teamId,
userId: message.user,
});
replyPlan.markSent();
return;
@@ -354,7 +356,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
skillFilter: prepared.channelConfig?.skills,
hasRepliedRef,
disableBlockStreaming: useStreaming
? false
? true
: typeof account.config.blockStreaming === "boolean"
? !account.config.blockStreaming
: undefined,

View File

@@ -36,6 +36,18 @@ export type StartSlackStreamParams = {
threadTs: string;
/** Optional initial markdown text to include in the stream start. */
text?: string;
/**
* The team ID of the workspace this stream belongs to.
* Required by the Slack API for `chat.startStream` / `chat.stopStream`.
* Obtain from `auth.test` response (`team_id`).
*/
teamId?: string;
/**
* The user ID of the message recipient (required for DM streaming).
* Without this, `chat.stopStream` fails with `missing_recipient_user_id`
* in direct message conversations.
*/
userId?: string;
};
export type AppendSlackStreamParams = {
@@ -64,13 +76,17 @@ export type StopSlackStreamParams = {
export async function startSlackStream(
params: StartSlackStreamParams,
): Promise<SlackStreamSession> {
const { client, channel, threadTs, text } = params;
const { client, channel, threadTs, text, teamId, userId } = params;
logVerbose(`slack-stream: starting stream in ${channel} thread=${threadTs}`);
logVerbose(
`slack-stream: starting stream in ${channel} thread=${threadTs}${teamId ? ` team=${teamId}` : ""}${userId ? ` user=${userId}` : ""}`,
);
const streamer = client.chatStream({
channel,
thread_ts: threadTs,
...(teamId ? { recipient_team_id: teamId } : {}),
...(userId ? { recipient_user_id: userId } : {}),
});
const session: SlackStreamSession = {

View File

@@ -635,6 +635,16 @@
border-color: rgba(34, 197, 94, 0.35);
}
.compaction-indicator--fallback {
color: #d97706;
border-color: rgba(217, 119, 6, 0.35);
}
.compaction-indicator--fallback-cleared {
color: var(--ok);
border-color: rgba(34, 197, 94, 0.35);
}
@keyframes compaction-spin {
to {
transform: rotate(360deg);

View File

@@ -824,6 +824,7 @@ export function renderApp(state: AppViewState) {
loading: state.chatLoading,
sending: state.chatSending,
compactionStatus: state.compactionStatus,
fallbackStatus: state.fallbackStatus,
assistantAvatarUrl: chatAvatarUrl,
messages: state.chatMessages,
toolMessages: state.chatToolMessages,

View File

@@ -0,0 +1,139 @@
import { beforeAll, describe, expect, it, vi } from "vitest";
import { handleAgentEvent, type FallbackStatus, type ToolStreamEntry } from "./app-tool-stream.ts";
type ToolStreamHost = Parameters<typeof handleAgentEvent>[0];
type MutableHost = ToolStreamHost & {
compactionStatus?: unknown;
compactionClearTimer?: number | null;
fallbackStatus?: FallbackStatus | null;
fallbackClearTimer?: number | null;
};
function createHost(overrides?: Partial<MutableHost>): MutableHost {
return {
sessionKey: "main",
chatRunId: null,
toolStreamById: new Map<string, ToolStreamEntry>(),
toolStreamOrder: [],
chatToolMessages: [],
toolStreamSyncTimer: null,
compactionStatus: null,
compactionClearTimer: null,
fallbackStatus: null,
fallbackClearTimer: null,
...overrides,
};
}
describe("app-tool-stream fallback lifecycle handling", () => {
beforeAll(() => {
const globalWithWindow = globalThis as typeof globalThis & {
window?: Window & typeof globalThis;
};
if (!globalWithWindow.window) {
globalWithWindow.window = globalThis as unknown as Window & typeof globalThis;
}
});
it("accepts session-scoped fallback lifecycle events when no run is active", () => {
vi.useFakeTimers();
const host = createHost();
handleAgentEvent(host, {
runId: "run-1",
seq: 1,
stream: "lifecycle",
ts: Date.now(),
sessionKey: "main",
data: {
phase: "fallback",
selectedProvider: "fireworks",
selectedModel: "fireworks/minimax-m2p5",
activeProvider: "deepinfra",
activeModel: "moonshotai/Kimi-K2.5",
reasonSummary: "rate limit",
},
});
expect(host.fallbackStatus?.selected).toBe("fireworks/minimax-m2p5");
expect(host.fallbackStatus?.active).toBe("deepinfra/moonshotai/Kimi-K2.5");
expect(host.fallbackStatus?.reason).toBe("rate limit");
vi.useRealTimers();
});
it("rejects idle fallback lifecycle events for other sessions", () => {
vi.useFakeTimers();
const host = createHost();
handleAgentEvent(host, {
runId: "run-1",
seq: 1,
stream: "lifecycle",
ts: Date.now(),
sessionKey: "agent:other:main",
data: {
phase: "fallback",
selectedProvider: "fireworks",
selectedModel: "fireworks/minimax-m2p5",
activeProvider: "deepinfra",
activeModel: "moonshotai/Kimi-K2.5",
},
});
expect(host.fallbackStatus).toBeNull();
vi.useRealTimers();
});
it("auto-clears fallback status after toast duration", () => {
vi.useFakeTimers();
const host = createHost();
handleAgentEvent(host, {
runId: "run-1",
seq: 1,
stream: "lifecycle",
ts: Date.now(),
sessionKey: "main",
data: {
phase: "fallback",
selectedProvider: "fireworks",
selectedModel: "fireworks/minimax-m2p5",
activeProvider: "deepinfra",
activeModel: "moonshotai/Kimi-K2.5",
},
});
expect(host.fallbackStatus).not.toBeNull();
vi.advanceTimersByTime(7_999);
expect(host.fallbackStatus).not.toBeNull();
vi.advanceTimersByTime(1);
expect(host.fallbackStatus).toBeNull();
vi.useRealTimers();
});
it("builds previous fallback label from provider + model on fallback_cleared", () => {
vi.useFakeTimers();
const host = createHost();
handleAgentEvent(host, {
runId: "run-1",
seq: 1,
stream: "lifecycle",
ts: Date.now(),
sessionKey: "main",
data: {
phase: "fallback_cleared",
selectedProvider: "fireworks",
selectedModel: "fireworks/minimax-m2p5",
activeProvider: "fireworks",
activeModel: "fireworks/minimax-m2p5",
previousActiveProvider: "deepinfra",
previousActiveModel: "moonshotai/Kimi-K2.5",
},
});
expect(host.fallbackStatus?.phase).toBe("cleared");
expect(host.fallbackStatus?.previous).toBe("deepinfra/moonshotai/Kimi-K2.5");
vi.useRealTimers();
});
});

View File

@@ -34,6 +34,82 @@ type ToolStreamHost = {
toolStreamSyncTimer: number | null;
};
function toTrimmedString(value: unknown): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed ? trimmed : null;
}
function resolveModelLabel(provider: unknown, model: unknown): string | null {
const modelValue = toTrimmedString(model);
if (!modelValue) {
return null;
}
const providerValue = toTrimmedString(provider);
if (providerValue) {
const prefix = `${providerValue}/`;
if (modelValue.toLowerCase().startsWith(prefix.toLowerCase())) {
const trimmedModel = modelValue.slice(prefix.length).trim();
if (trimmedModel) {
return `${providerValue}/${trimmedModel}`;
}
}
return `${providerValue}/${modelValue}`;
}
const slashIndex = modelValue.indexOf("/");
if (slashIndex > 0) {
const p = modelValue.slice(0, slashIndex).trim();
const m = modelValue.slice(slashIndex + 1).trim();
if (p && m) {
return `${p}/${m}`;
}
}
return modelValue;
}
type FallbackAttempt = {
provider: string;
model: string;
reason: string;
};
function parseFallbackAttemptSummaries(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return value
.map((entry) => toTrimmedString(entry))
.filter((entry): entry is string => Boolean(entry));
}
function parseFallbackAttempts(value: unknown): FallbackAttempt[] {
if (!Array.isArray(value)) {
return [];
}
const out: FallbackAttempt[] = [];
for (const entry of value) {
if (!entry || typeof entry !== "object") {
continue;
}
const item = entry as Record<string, unknown>;
const provider = toTrimmedString(item.provider);
const model = toTrimmedString(item.model);
if (!provider || !model) {
continue;
}
const reason =
toTrimmedString(item.reason)?.replace(/_/g, " ") ??
toTrimmedString(item.code) ??
(typeof item.status === "number" ? `HTTP ${item.status}` : null) ??
toTrimmedString(item.error) ??
"error";
out.push({ provider, model, reason });
}
return out;
}
function extractToolOutputText(value: unknown): string | null {
if (!value || typeof value !== "object") {
return null;
@@ -167,12 +243,25 @@ export type CompactionStatus = {
completedAt: number | null;
};
export type FallbackStatus = {
phase?: "active" | "cleared";
selected: string;
active: string;
previous?: string;
reason?: string;
attempts: string[];
occurredAt: number;
};
type CompactionHost = ToolStreamHost & {
compactionStatus?: CompactionStatus | null;
compactionClearTimer?: number | null;
fallbackStatus?: FallbackStatus | null;
fallbackClearTimer?: number | null;
};
const COMPACTION_TOAST_DURATION_MS = 5000;
const FALLBACK_TOAST_DURATION_MS = 8000;
export function handleCompactionEvent(host: CompactionHost, payload: AgentEventPayload) {
const data = payload.data ?? {};
@@ -204,6 +293,95 @@ export function handleCompactionEvent(host: CompactionHost, payload: AgentEventP
}
}
function resolveAcceptedSession(
host: ToolStreamHost,
payload: AgentEventPayload,
options?: {
allowSessionScopedWhenIdle?: boolean;
},
): { accepted: boolean; sessionKey?: string } {
const sessionKey = typeof payload.sessionKey === "string" ? payload.sessionKey : undefined;
if (sessionKey && sessionKey !== host.sessionKey) {
return { accepted: false };
}
if (!host.chatRunId && options?.allowSessionScopedWhenIdle && sessionKey) {
return { accepted: true, sessionKey };
}
// Fallback: only accept session-less events for the active run.
if (!sessionKey && host.chatRunId && payload.runId !== host.chatRunId) {
return { accepted: false };
}
if (host.chatRunId && payload.runId !== host.chatRunId) {
return { accepted: false };
}
if (!host.chatRunId) {
return { accepted: false };
}
return { accepted: true, sessionKey };
}
function handleLifecycleFallbackEvent(host: CompactionHost, payload: AgentEventPayload) {
const data = payload.data ?? {};
const phase = payload.stream === "fallback" ? "fallback" : toTrimmedString(data.phase);
if (payload.stream === "lifecycle" && phase !== "fallback" && phase !== "fallback_cleared") {
return;
}
const accepted = resolveAcceptedSession(host, payload, { allowSessionScopedWhenIdle: true });
if (!accepted.accepted) {
return;
}
const selected =
resolveModelLabel(data.selectedProvider, data.selectedModel) ??
resolveModelLabel(data.fromProvider, data.fromModel);
const active =
resolveModelLabel(data.activeProvider, data.activeModel) ??
resolveModelLabel(data.toProvider, data.toModel);
const previous =
resolveModelLabel(data.previousActiveProvider, data.previousActiveModel) ??
toTrimmedString(data.previousActiveModel);
if (!selected || !active) {
return;
}
if (phase === "fallback" && selected === active) {
return;
}
const reason = toTrimmedString(data.reasonSummary) ?? toTrimmedString(data.reason);
const attempts = (() => {
const summaries = parseFallbackAttemptSummaries(data.attemptSummaries);
if (summaries.length > 0) {
return summaries;
}
return parseFallbackAttempts(data.attempts).map((attempt) => {
const modelRef = resolveModelLabel(attempt.provider, attempt.model);
return `${modelRef ?? `${attempt.provider}/${attempt.model}`}: ${attempt.reason}`;
});
})();
if (host.fallbackClearTimer != null) {
window.clearTimeout(host.fallbackClearTimer);
host.fallbackClearTimer = null;
}
host.fallbackStatus = {
phase: phase === "fallback_cleared" ? "cleared" : "active",
selected,
active: phase === "fallback_cleared" ? selected : active,
previous:
phase === "fallback_cleared"
? (previous ?? (active !== selected ? active : undefined))
: undefined,
reason: reason ?? undefined,
attempts,
occurredAt: Date.now(),
};
host.fallbackClearTimer = window.setTimeout(() => {
host.fallbackStatus = null;
host.fallbackClearTimer = null;
}, FALLBACK_TOAST_DURATION_MS);
}
export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPayload) {
if (!payload) {
return;
@@ -215,23 +393,19 @@ export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPaylo
return;
}
if (payload.stream === "lifecycle" || payload.stream === "fallback") {
handleLifecycleFallbackEvent(host as CompactionHost, payload);
return;
}
if (payload.stream !== "tool") {
return;
}
const sessionKey = typeof payload.sessionKey === "string" ? payload.sessionKey : undefined;
if (sessionKey && sessionKey !== host.sessionKey) {
return;
}
// Fallback: only accept session-less events for the active run.
if (!sessionKey && host.chatRunId && payload.runId !== host.chatRunId) {
return;
}
if (host.chatRunId && payload.runId !== host.chatRunId) {
return;
}
if (!host.chatRunId) {
const accepted = resolveAcceptedSession(host, payload);
if (!accepted.accepted) {
return;
}
const sessionKey = accepted.sessionKey;
const data = payload.data ?? {};
const toolCallId = typeof data.toolCallId === "string" ? data.toolCallId : "";

View File

@@ -1,5 +1,5 @@
import type { EventLogEntry } from "./app-events.ts";
import type { CompactionStatus } from "./app-tool-stream.ts";
import type { CompactionStatus, FallbackStatus } from "./app-tool-stream.ts";
import type { DevicePairingList } from "./controllers/devices.ts";
import type { ExecApprovalRequest } from "./controllers/exec-approval.ts";
import type { ExecApprovalsFile, ExecApprovalsSnapshot } from "./controllers/exec-approvals.ts";
@@ -61,6 +61,7 @@ export type AppViewState = {
chatStreamStartedAt: number | null;
chatRunId: string | null;
compactionStatus: CompactionStatus | null;
fallbackStatus: FallbackStatus | null;
chatAvatarUrl: string | null;
chatThinkingLevel: string | null;
chatQueue: ChatQueueItem[];

View File

@@ -48,6 +48,7 @@ import {
resetToolStream as resetToolStreamInternal,
type ToolStreamEntry,
type CompactionStatus,
type FallbackStatus,
} from "./app-tool-stream.ts";
import type { AppViewState } from "./app-view-state.ts";
import { normalizeAssistantIdentity } from "./assistant-identity.ts";
@@ -140,6 +141,7 @@ export class OpenClawApp extends LitElement {
@state() chatStreamStartedAt: number | null = null;
@state() chatRunId: string | null = null;
@state() compactionStatus: CompactionStatus | null = null;
@state() fallbackStatus: FallbackStatus | null = null;
@state() chatAvatarUrl: string | null = null;
@state() chatThinkingLevel: string | null = null;
@state() chatQueue: ChatQueueItem[] = [];

View File

@@ -23,6 +23,7 @@ function createProps(overrides: Partial<ChatProps> = {}): ChatProps {
sending: false,
canAbort: false,
compactionStatus: null,
fallbackStatus: null,
messages: [],
toolMessages: [],
stream: null,
@@ -111,6 +112,75 @@ describe("chat view", () => {
nowSpy.mockRestore();
});
it("renders fallback indicator shortly after fallback event", () => {
const container = document.createElement("div");
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_000);
render(
renderChat(
createProps({
fallbackStatus: {
selected: "fireworks/minimax-m2p5",
active: "deepinfra/moonshotai/Kimi-K2.5",
attempts: ["fireworks/minimax-m2p5: rate limit"],
occurredAt: 900,
},
}),
),
container,
);
const indicator = container.querySelector(".compaction-indicator--fallback");
expect(indicator).not.toBeNull();
expect(indicator?.textContent).toContain("Fallback active: deepinfra/moonshotai/Kimi-K2.5");
nowSpy.mockRestore();
});
it("hides stale fallback indicator", () => {
const container = document.createElement("div");
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(20_000);
render(
renderChat(
createProps({
fallbackStatus: {
selected: "fireworks/minimax-m2p5",
active: "deepinfra/moonshotai/Kimi-K2.5",
attempts: [],
occurredAt: 0,
},
}),
),
container,
);
expect(container.querySelector(".compaction-indicator--fallback")).toBeNull();
nowSpy.mockRestore();
});
it("renders fallback-cleared indicator shortly after transition", () => {
const container = document.createElement("div");
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_000);
render(
renderChat(
createProps({
fallbackStatus: {
phase: "cleared",
selected: "fireworks/minimax-m2p5",
active: "fireworks/minimax-m2p5",
previous: "deepinfra/moonshotai/Kimi-K2.5",
attempts: [],
occurredAt: 900,
},
}),
),
container,
);
const indicator = container.querySelector(".compaction-indicator--fallback-cleared");
expect(indicator).not.toBeNull();
expect(indicator?.textContent).toContain("Fallback cleared: fireworks/minimax-m2p5");
nowSpy.mockRestore();
});
it("shows a stop button when aborting is available", () => {
const container = document.createElement("div");
const onAbort = vi.fn();

View File

@@ -21,6 +21,16 @@ export type CompactionIndicatorStatus = {
completedAt: number | null;
};
export type FallbackIndicatorStatus = {
phase?: "active" | "cleared";
selected: string;
active: string;
previous?: string;
reason?: string;
attempts: string[];
occurredAt: number;
};
export type ChatProps = {
sessionKey: string;
onSessionKeyChange: (next: string) => void;
@@ -30,6 +40,7 @@ export type ChatProps = {
sending: boolean;
canAbort?: boolean;
compactionStatus?: CompactionIndicatorStatus | null;
fallbackStatus?: FallbackIndicatorStatus | null;
messages: unknown[];
toolMessages: unknown[];
stream: string | null;
@@ -72,6 +83,7 @@ export type ChatProps = {
};
const COMPACTION_TOAST_DURATION_MS = 5000;
const FALLBACK_TOAST_DURATION_MS = 8000;
function adjustTextareaHeight(el: HTMLTextAreaElement) {
el.style.height = "auto";
@@ -107,6 +119,45 @@ function renderCompactionIndicator(status: CompactionIndicatorStatus | null | un
return nothing;
}
function renderFallbackIndicator(status: FallbackIndicatorStatus | null | undefined) {
if (!status) {
return nothing;
}
const phase = status.phase ?? "active";
const elapsed = Date.now() - status.occurredAt;
if (elapsed >= FALLBACK_TOAST_DURATION_MS) {
return nothing;
}
const details = [
`Selected: ${status.selected}`,
phase === "cleared" ? `Active: ${status.selected}` : `Active: ${status.active}`,
phase === "cleared" && status.previous ? `Previous fallback: ${status.previous}` : null,
status.reason ? `Reason: ${status.reason}` : null,
status.attempts.length > 0 ? `Attempts: ${status.attempts.slice(0, 3).join(" | ")}` : null,
]
.filter(Boolean)
.join(" • ");
const message =
phase === "cleared"
? `Fallback cleared: ${status.selected}`
: `Fallback active: ${status.active}`;
const className =
phase === "cleared"
? "compaction-indicator compaction-indicator--fallback-cleared"
: "compaction-indicator compaction-indicator--fallback";
const icon = phase === "cleared" ? icons.check : icons.brain;
return html`
<div
class=${className}
role="status"
aria-live="polite"
title=${details}
>
${icon} ${message}
</div>
`;
}
function generateAttachmentId(): string {
return `att-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}
@@ -352,6 +403,7 @@ export function renderChat(props: ChatProps) {
: nothing
}
${renderFallbackIndicator(props.fallbackStatus)}
${renderCompactionIndicator(props.compactionStatus)}
${