Skip to content

Commit d8ff8ca

Browse files
Auto-merge upstream openclaw/openclaw
2 parents 8bfa42b + 87a0390 commit d8ff8ca

34 files changed

Lines changed: 1864 additions & 194 deletions

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,9 @@ Docs: https://docs.openclaw.ai
117117
- CLI/Commander: preserve Commander-computed exit codes for argument and help-error paths, and cover the user-argv parse mode in the regression tests so invalid CLI invocations no longer report success when exits are intercepted. (#60923) Thanks @Linux2010.
118118
- Telegram/native command menu: trim long menu descriptions before dropping commands so sub-100 command sets can still fit Telegram's payload budget and keep more `/` entries visible. (#61129) Thanks @neeravmakwana.
119119
- Agents/Claude CLI: keep non-interactive `--permission-mode bypassPermissions` when custom `cliBackends.claude-cli.args` override defaults, so cron and heartbeat Claude CLI runs do not regress to interactive approval mode. (#61114) Thanks @cathrynlavery and @thewilloftheshadow.
120+
- Agents/skills: skip `.git` and `node_modules` when mirroring skills into sandbox workspaces so read-only sandboxes do not copy repo history or dependency trees. (#61090) Thanks @joelnishanth.
121+
- Android/Talk Mode: cancel in-flight `talk.speak` playback when speech is explicitly stopped, so stale replies stop starting after barge-in or manual stop. (#61164) Thanks @obviyus.
122+
- Plugins/onboarding: write dotted plugin uiHint paths like Brave `webSearch.mode` as nested plugin config so `llm-context` setup stops failing validation. (#61159) Thanks @obviyus.
120123

121124
## 2026.4.2
122125

apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,6 @@ class TalkModeManager(
266266
if (playbackEnabled == enabled) return
267267
playbackEnabled = enabled
268268
if (!enabled) {
269-
playbackGeneration.incrementAndGet()
270269
stopSpeaking()
271270
}
272271
}
@@ -742,6 +741,7 @@ class TalkModeManager(
742741
ttsJob
743742
}
744743
activeJob?.cancel()
744+
talkAudioPlayer.stop()
745745
stopTextToSpeechPlayback()
746746
}
747747

@@ -829,17 +829,16 @@ class TalkModeManager(
829829
}
830830

831831
private fun stopSpeaking(resetInterrupt: Boolean = true) {
832+
playbackGeneration.incrementAndGet()
832833
if (!_isSpeaking.value) {
833-
talkAudioPlayer.stop()
834-
stopTextToSpeechPlayback()
834+
cancelActivePlayback()
835835
abandonAudioFocus()
836836
return
837837
}
838838
if (resetInterrupt) {
839839
lastInterruptedAtSeconds = null
840840
}
841-
talkAudioPlayer.stop()
842-
stopTextToSpeechPlayback()
841+
cancelActivePlayback()
843842
_isSpeaking.value = false
844843
abandonAudioFocus()
845844
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package ai.openclaw.app.voice
2+
3+
import ai.openclaw.app.gateway.DeviceAuthEntry
4+
import ai.openclaw.app.gateway.DeviceAuthTokenStore
5+
import ai.openclaw.app.gateway.DeviceIdentityStore
6+
import ai.openclaw.app.gateway.GatewaySession
7+
import java.util.concurrent.atomic.AtomicLong
8+
import kotlinx.coroutines.CoroutineScope
9+
import kotlinx.coroutines.Dispatchers
10+
import kotlinx.coroutines.Job
11+
import kotlinx.coroutines.SupervisorJob
12+
import org.junit.Assert.assertEquals
13+
import org.junit.Assert.assertTrue
14+
import org.junit.Test
15+
import org.junit.runner.RunWith
16+
import org.robolectric.RobolectricTestRunner
17+
import org.robolectric.RuntimeEnvironment
18+
import org.robolectric.annotation.Config
19+
20+
@RunWith(RobolectricTestRunner::class)
21+
@Config(sdk = [34])
22+
class TalkModeManagerTest {
23+
@Test
24+
fun stopTtsCancelsTrackedPlaybackJob() {
25+
val manager = createManager()
26+
val playbackJob = Job()
27+
28+
setPrivateField(manager, "ttsJob", playbackJob)
29+
playbackGeneration(manager).set(7L)
30+
31+
manager.stopTts()
32+
33+
assertTrue(playbackJob.isCancelled)
34+
assertEquals(8L, playbackGeneration(manager).get())
35+
}
36+
37+
@Test
38+
fun disablingPlaybackCancelsTrackedJobOnce() {
39+
val manager = createManager()
40+
val playbackJob = Job()
41+
42+
setPrivateField(manager, "ttsJob", playbackJob)
43+
playbackGeneration(manager).set(11L)
44+
45+
manager.setPlaybackEnabled(false)
46+
manager.setPlaybackEnabled(false)
47+
48+
assertTrue(playbackJob.isCancelled)
49+
assertEquals(12L, playbackGeneration(manager).get())
50+
}
51+
52+
private fun createManager(): TalkModeManager {
53+
val app = RuntimeEnvironment.getApplication()
54+
val sessionJob = SupervisorJob()
55+
val session =
56+
GatewaySession(
57+
scope = CoroutineScope(sessionJob + Dispatchers.Default),
58+
identityStore = DeviceIdentityStore(app),
59+
deviceAuthStore = InMemoryDeviceAuthStore(),
60+
onConnected = { _, _, _ -> },
61+
onDisconnected = {},
62+
onEvent = { _, _ -> },
63+
)
64+
return TalkModeManager(
65+
context = app,
66+
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
67+
session = session,
68+
supportsChatSubscribe = false,
69+
isConnected = { true },
70+
)
71+
}
72+
73+
@Suppress("UNCHECKED_CAST")
74+
private fun playbackGeneration(manager: TalkModeManager): AtomicLong {
75+
return readPrivateField(manager, "playbackGeneration") as AtomicLong
76+
}
77+
78+
private fun setPrivateField(target: Any, name: String, value: Any?) {
79+
val field = target.javaClass.getDeclaredField(name)
80+
field.isAccessible = true
81+
field.set(target, value)
82+
}
83+
84+
private fun readPrivateField(target: Any, name: String): Any? {
85+
val field = target.javaClass.getDeclaredField(name)
86+
field.isAccessible = true
87+
return field.get(target)
88+
}
89+
}
90+
91+
private class InMemoryDeviceAuthStore : DeviceAuthTokenStore {
92+
override fun loadEntry(deviceId: String, role: String): DeviceAuthEntry? = null
93+
94+
override fun saveToken(deviceId: String, role: String, token: String, scopes: List<String>) = Unit
95+
96+
override fun clearToken(deviceId: String, role: String) = Unit
97+
}

extensions/discord/src/doctor-contract.ts

Lines changed: 119 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,98 @@ function asObjectRecord(value: unknown): Record<string, unknown> | null {
1111
: null;
1212
}
1313

14+
function normalizeDiscordDmAliases(params: {
15+
entry: Record<string, unknown>;
16+
pathPrefix: string;
17+
changes: string[];
18+
}): { entry: Record<string, unknown>; changed: boolean } {
19+
let changed = false;
20+
let updated: Record<string, unknown> = params.entry;
21+
const rawDm = updated.dm;
22+
const dm = asObjectRecord(rawDm) ? (structuredClone(rawDm) as Record<string, unknown>) : null;
23+
let dmChanged = false;
24+
const shouldPromoteLegacyAllowFrom = !(
25+
params.pathPrefix === "channels.discord" && asObjectRecord(updated.accounts)
26+
);
27+
28+
const allowFromEqual = (a: unknown, b: unknown): boolean => {
29+
if (!Array.isArray(a) || !Array.isArray(b)) {
30+
return false;
31+
}
32+
const na = a.map((v) => String(v).trim()).filter(Boolean);
33+
const nb = b.map((v) => String(v).trim()).filter(Boolean);
34+
if (na.length !== nb.length) {
35+
return false;
36+
}
37+
return na.every((v, i) => v === nb[i]);
38+
};
39+
40+
const topDmPolicy = updated.dmPolicy;
41+
const legacyDmPolicy = dm?.policy;
42+
if (topDmPolicy === undefined && legacyDmPolicy !== undefined) {
43+
updated = { ...updated, dmPolicy: legacyDmPolicy };
44+
changed = true;
45+
if (dm) {
46+
delete dm.policy;
47+
dmChanged = true;
48+
}
49+
params.changes.push(`Moved ${params.pathPrefix}.dm.policy → ${params.pathPrefix}.dmPolicy.`);
50+
} else if (
51+
topDmPolicy !== undefined &&
52+
legacyDmPolicy !== undefined &&
53+
topDmPolicy === legacyDmPolicy
54+
) {
55+
if (dm) {
56+
delete dm.policy;
57+
dmChanged = true;
58+
params.changes.push(`Removed ${params.pathPrefix}.dm.policy (dmPolicy already set).`);
59+
}
60+
}
61+
62+
const topAllowFrom = updated.allowFrom;
63+
const legacyAllowFrom = dm?.allowFrom;
64+
if (shouldPromoteLegacyAllowFrom) {
65+
if (topAllowFrom === undefined && legacyAllowFrom !== undefined) {
66+
updated = { ...updated, allowFrom: legacyAllowFrom };
67+
changed = true;
68+
if (dm) {
69+
delete dm.allowFrom;
70+
dmChanged = true;
71+
}
72+
params.changes.push(
73+
`Moved ${params.pathPrefix}.dm.allowFrom → ${params.pathPrefix}.allowFrom.`,
74+
);
75+
} else if (
76+
topAllowFrom !== undefined &&
77+
legacyAllowFrom !== undefined &&
78+
allowFromEqual(topAllowFrom, legacyAllowFrom)
79+
) {
80+
if (dm) {
81+
delete dm.allowFrom;
82+
dmChanged = true;
83+
params.changes.push(`Removed ${params.pathPrefix}.dm.allowFrom (allowFrom already set).`);
84+
}
85+
}
86+
}
87+
88+
if (dm && asObjectRecord(rawDm) && dmChanged) {
89+
const keys = Object.keys(dm);
90+
if (keys.length === 0) {
91+
if (updated.dm !== undefined) {
92+
const { dm: _ignored, ...rest } = updated;
93+
updated = rest;
94+
changed = true;
95+
params.changes.push(`Removed empty ${params.pathPrefix}.dm after migration.`);
96+
}
97+
} else {
98+
updated = { ...updated, dm };
99+
changed = true;
100+
}
101+
}
102+
103+
return { entry: updated, changed };
104+
}
105+
14106
function normalizeDiscordStreamingAliases(params: {
15107
entry: Record<string, unknown>;
16108
pathPrefix: string;
@@ -219,6 +311,14 @@ export function normalizeCompatibilityConfig({
219311
let updated = rawEntry;
220312
let changed = false;
221313

314+
const dm = normalizeDiscordDmAliases({
315+
entry: updated,
316+
pathPrefix: "channels.discord",
317+
changes,
318+
});
319+
updated = dm.entry;
320+
changed = changed || dm.changed;
321+
222322
const streaming = normalizeDiscordStreamingAliases({
223323
entry: updated,
224324
pathPrefix: "channels.discord",
@@ -236,16 +336,23 @@ export function normalizeCompatibilityConfig({
236336
if (!account) {
237337
continue;
238338
}
339+
let accountEntry = account;
340+
let accountChanged = false;
341+
const accountDm = normalizeDiscordDmAliases({
342+
entry: accountEntry,
343+
pathPrefix: `channels.discord.accounts.${accountId}`,
344+
changes,
345+
});
346+
accountEntry = accountDm.entry;
347+
accountChanged = accountDm.changed;
239348
const accountStreaming = normalizeDiscordStreamingAliases({
240-
entry: account,
349+
entry: accountEntry,
241350
pathPrefix: `channels.discord.accounts.${accountId}`,
242351
changes,
243352
});
244-
if (accountStreaming.changed) {
245-
accounts[accountId] = accountStreaming.entry;
246-
accountsChanged = true;
247-
}
248-
const accountVoice = asObjectRecord(accountStreaming.entry.voice);
353+
accountEntry = accountStreaming.entry;
354+
accountChanged = accountChanged || accountStreaming.changed;
355+
const accountVoice = asObjectRecord(accountEntry.voice);
249356
if (
250357
accountVoice &&
251358
migrateLegacyTtsConfig(
@@ -254,10 +361,14 @@ export function normalizeCompatibilityConfig({
254361
changes,
255362
)
256363
) {
257-
accounts[accountId] = {
258-
...accountStreaming.entry,
364+
accountEntry = {
365+
...accountEntry,
259366
voice: accountVoice,
260367
};
368+
accountChanged = true;
369+
}
370+
if (accountChanged) {
371+
accounts[accountId] = accountEntry;
261372
accountsChanged = true;
262373
}
263374
}

extensions/openrouter/index.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,9 @@ describe("openrouter provider hooks", () => {
8585
);
8686

8787
expect(baseStreamFn).toHaveBeenCalledOnce();
88-
expect(baseStreamFn.mock.calls[0]?.[0]).toMatchObject({
88+
const firstCall = baseStreamFn.mock.calls[0];
89+
const firstModel = firstCall?.[0];
90+
expect(firstModel).toMatchObject({
8991
compat: {
9092
openRouterRouting: {
9193
order: ["moonshot"],

extensions/xai/stream.test.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@ import {
1010
type ToolPayload = {
1111
function?: Record<string, unknown>;
1212
};
13-
13+
type XaiTestPayload = Record<string, unknown> & {
14+
tools?: Array<{ type?: string; function?: Record<string, unknown> }>;
15+
input?: unknown[];
16+
};
1417
function captureWrappedModelId(params: {
1518
modelId: string;
1619
fastMode: boolean;
@@ -63,10 +66,10 @@ describe("xai stream wrappers", () => {
6366

6467
it("composes the xai provider stream chain from extra params", () => {
6568
let capturedModelId = "";
66-
let capturedPayload: Record<string, unknown> | undefined;
69+
let capturedPayload: XaiTestPayload | undefined;
6770
const baseStreamFn: StreamFn = (model, _context, options) => {
6871
capturedModelId = String(model.id);
69-
const payload: Record<string, unknown> = {
72+
const payload: XaiTestPayload = {
7073
reasoning: { effort: "high" },
7174
tools: [
7275
{

src/agents/model-selection.ts

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
toAgentModelListLike,
77
} from "../config/model-input.js";
88
import { createSubsystemLogger } from "../logging/subsystem.js";
9-
import { sanitizeForLog } from "../terminal/ansi.js";
9+
import { sanitizeForLog, stripAnsi } from "../terminal/ansi.js";
1010
import {
1111
resolveAgentConfig,
1212
resolveAgentEffectiveModelPrimary,
@@ -72,6 +72,22 @@ function normalizeAliasKey(value: string): string {
7272
return value.trim().toLowerCase();
7373
}
7474

75+
function sanitizeModelWarningValue(value: string): string {
76+
const stripped = value ? stripAnsi(value) : "";
77+
let controlBoundary = -1;
78+
for (let index = 0; index < stripped.length; index += 1) {
79+
const code = stripped.charCodeAt(index);
80+
if (code <= 0x1f || code === 0x7f) {
81+
controlBoundary = index;
82+
break;
83+
}
84+
}
85+
if (controlBoundary === -1) {
86+
return sanitizeForLog(stripped);
87+
}
88+
return sanitizeForLog(stripped.slice(0, controlBoundary));
89+
}
90+
7591
export function modelKey(provider: string, model: string) {
7692
return sharedModelKey(provider, model);
7793
}
@@ -363,16 +379,8 @@ export function resolveConfiguredModelRef(params: {
363379
return aliasMatch.ref;
364380
}
365381

366-
const inferredProvider = inferUniqueProviderFromConfiguredModels({
367-
cfg: params.cfg,
368-
model: trimmed,
369-
});
370-
if (inferredProvider) {
371-
return { provider: inferredProvider, model: trimmed };
372-
}
373-
374382
// Default to the configured provider if no provider is specified, but warn as this is deprecated.
375-
const safeTrimmed = sanitizeForLog(trimmed);
383+
const safeTrimmed = sanitizeModelWarningValue(trimmed);
376384
const safeResolved = sanitizeForLog(`${params.defaultProvider}/${safeTrimmed}`);
377385
getLog().warn(
378386
`Model "${safeTrimmed}" specified without provider. Falling back to "${safeResolved}". Please use "${safeResolved}" in your config.`,
@@ -404,7 +412,6 @@ export function resolveConfiguredModelRef(params: {
404412
const fallbackProvider = resolveConfiguredProviderFallback({
405413
cfg: params.cfg,
406414
defaultProvider: params.defaultProvider,
407-
defaultModel: params.defaultModel,
408415
});
409416
if (fallbackProvider) {
410417
return fallbackProvider;

0 commit comments

Comments
 (0)