Skip to content

Commit 2eb972c

Browse files

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+876
-227
lines changed

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,9 @@ jobs:
159159
- runtime: node
160160
task: extensions
161161
command: pnpm test:extensions
162+
- runtime: node
163+
task: channels
164+
command: pnpm test:channels
162165
- runtime: node
163166
task: protocol
164167
command: pnpm protocol:check

.github/workflows/docker-release.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,9 @@ jobs:
5959
environment: docker-release
6060
steps:
6161
- name: Approve Docker backfill
62-
run: echo "Approved Docker backfill for ${{ inputs.tag }}"
62+
env:
63+
RELEASE_TAG: ${{ inputs.tag }}
64+
run: echo "Approved Docker backfill for $RELEASE_TAG"
6365

6466
# KEEP THIS WORKFLOW ON GITHUB-HOSTED RUNNERS.
6567
# DO NOT MOVE IT BACK TO BLACKSMITH WITHOUT RE-VALIDATING TAG BUILDS AND BACKFILLS.

.github/workflows/openclaw-npm-release.yml

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ on:
77
workflow_dispatch:
88
inputs:
99
tag:
10-
description: Release tag to publish (for example v2026.3.14 or v2026.3.14-beta.1)
10+
description: Release tag to publish (for example v2026.3.14, v2026.3.14-beta.1, or fallback v2026.3.14-1)
1111
required: true
1212
type: string
1313

@@ -47,9 +47,18 @@ jobs:
4747
set -euo pipefail
4848
RELEASE_SHA=$(git rev-parse HEAD)
4949
PACKAGE_VERSION=$(node -p "require('./package.json').version")
50+
if [[ "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*-[1-9][0-9]*$ ]]; then
51+
TAG_KIND="fallback correction"
52+
else
53+
TAG_KIND="standard"
54+
fi
5055
echo "Release plan for ${RELEASE_TAG}:"
5156
echo "Resolved release SHA: ${RELEASE_SHA}"
5257
echo "Resolved package version: ${PACKAGE_VERSION}"
58+
echo "Resolved tag kind: ${TAG_KIND}"
59+
if [[ "${TAG_KIND}" == "fallback correction" ]]; then
60+
echo "Correction tag note: npm version remains ${PACKAGE_VERSION}"
61+
fi
5362
echo "Would run: git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main"
5463
echo "Would run with env: RELEASE_SHA=${RELEASE_SHA} RELEASE_TAG=${RELEASE_TAG} RELEASE_MAIN_REF=origin/main pnpm release:openclaw:npm:check"
5564
echo "Would run: npm view openclaw@${PACKAGE_VERSION} version"
@@ -71,16 +80,31 @@ jobs:
7180
pnpm release:openclaw:npm:check
7281
7382
- name: Ensure version is not already published
83+
env:
84+
RELEASE_TAG: ${{ github.ref_name }}
7485
run: |
7586
set -euxo pipefail
7687
PACKAGE_VERSION=$(node -p "require('./package.json').version")
88+
IS_CORRECTION_TAG=0
89+
if [[ "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*-[1-9][0-9]*$ ]]; then
90+
IS_CORRECTION_TAG=1
91+
fi
7792
7893
if npm view "openclaw@${PACKAGE_VERSION}" version >/dev/null 2>&1; then
94+
if [[ "${IS_CORRECTION_TAG}" == "1" ]]; then
95+
echo "openclaw@${PACKAGE_VERSION} is already published on npm."
96+
echo "Correction tag ${RELEASE_TAG} is allowed as a fallback release tag, so preview will continue without treating this as an error."
97+
exit 0
98+
fi
7999
echo "openclaw@${PACKAGE_VERSION} is already published on npm."
80100
exit 1
81101
fi
82102
83-
echo "Previewing openclaw@${PACKAGE_VERSION}"
103+
if [[ "${IS_CORRECTION_TAG}" == "1" ]]; then
104+
echo "Previewing fallback correction tag ${RELEASE_TAG} for npm version openclaw@${PACKAGE_VERSION}"
105+
else
106+
echo "Previewing openclaw@${PACKAGE_VERSION}"
107+
fi
84108
85109
- name: Check
86110
run: |
@@ -114,7 +138,7 @@ jobs:
114138
RELEASE_TAG: ${{ inputs.tag }}
115139
run: |
116140
set -euo pipefail
117-
if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-beta\.[1-9][0-9]*)?$ ]]; then
141+
if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*((-beta\.[1-9][0-9]*)|(-[1-9][0-9]*))?$ ]]; then
118142
echo "Invalid release tag format: ${RELEASE_TAG}"
119143
exit 1
120144
fi

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ Docs: https://docs.openclaw.ai
1818
### Fixes
1919

2020
- Slack/interactive replies: preserve `channelData.slack.blocks` through live DM delivery and preview-finalized edits so Block Kit button and select directives render instead of falling back to raw text. Thanks @vincentkoc.
21+
- CI/channel test routing: move the built-in channel suites into `test:channels` and keep them out of `test:extensions`, so extension CI no longer fails after the channel migration while targeted test routing still sends Slack, Signal, and iMessage suites to the right lane. (#46066) Thanks @scoootscooob.
22+
- Agents/usage tracking: stop forcing `supportsUsageInStreaming: false` on non-native openai-completions endpoints so providers like DashScope, DeepSeek, and other OpenAI-compatible backends report token usage and cost instead of showing all zeros. (#46142)
2123

2224
## 2026.3.13
2325

@@ -91,6 +93,8 @@ Docs: https://docs.openclaw.ai
9193
- Telegram/media errors: redact Telegram file URLs before building media fetch errors so failed inbound downloads do not leak bot tokens into logs. Thanks @space08.
9294
- Agents/failover: normalize abort-wrapped `429 RESOURCE_EXHAUSTED` provider failures before abort short-circuiting so wrapped Google/Vertex rate limits continue across configured fallback models, including the embedded runner prompt-error path. (#39820) Thanks @lupuletic.
9395
- Mattermost/thread routing: non-inbound reply paths (TUI/WebUI turns, tool-call callbacks, subagent responses) now correctly route to the originating Mattermost thread when `replyToMode: "all"` is active; also prevents stale `origin.threadId` metadata from resurrecting cleared thread routes. (#44283) thanks @teconomix
96+
- Gateway/websocket pairing bypass for disabled auth: skip device-pairing enforcement when `gateway.auth.mode=none` so Control UI connections behind reverse proxies no longer get stuck on `pairing required` (code 1008) despite auth being explicitly disabled. (#42931)
97+
- Auth/login lockout recovery: clear stale `auth_permanent` and `billing` disabled state for all profiles matching the target provider when `openclaw models auth login` is invoked, so users locked out by expired or revoked OAuth tokens can recover by re-authenticating instead of waiting for the cooldown timer to expire. (#43057)
9498

9599
## 2026.3.12
96100

@@ -323,6 +327,7 @@ Docs: https://docs.openclaw.ai
323327
- Agents/failover: classify HTTP 422 malformed-request responses as `format` and recognize OpenRouter "requires more credits" billing errors so provider fallback triggers instead of surfacing raw errors. (#43823) thanks @jnMetaCode.
324328
- Memory/QMD Windows: fail closed when `qmd.cmd` or `mcporter.cmd` wrappers cannot be resolved to a direct entrypoint, so memory search no longer falls back to shell execution on Windows.
325329
- macOS/remote gateway: stop PortGuardian from killing Docker Desktop and other external listeners on the gateway port in remote mode, so containerized and tunneled gateway setups no longer lose their port-forward owner on app startup. (#6755) Thanks @teslamint.
330+
- Feishu/streaming recovery: clear stale `streamingStartPromise` when card creation fails (HTTP 400) so subsequent messages can retry streaming instead of silently dropping all future replies. Fixes #43322.
326331

327332
## 2026.3.8
328333

CONTRIBUTING.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,9 @@ Welcome to the lobster tank! 🦞
7676
- **Tengji (George) Zhang** - Chinese model APIs, cloud, pi
7777
- GitHub: [@odysseus0](https://github.com/odysseus0) · X: [@odysseus0z](https://x.com/odysseus0z)
7878

79+
- **Andrew (Bubbles) Demczuk** - Agents/Gateway/TTS/VTT
80+
- GitHub: [@ademczuk](https://github.com/ademczuk) · X: [@ademczuk](https://x.com/ademczuk)
81+
7982
## How to Contribute
8083

8184
1. **Bugs & small fixes** → Open a PR!

docs/reference/RELEASING.md

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ Current OpenClaw releases use date-based versioning.
2929
- Beta prerelease version: `YYYY.M.D-beta.N`
3030
- Git tag: `vYYYY.M.D-beta.N`
3131
- Examples from repo history: `v2026.2.15-beta.1`, `v2026.3.8-beta.1`
32+
- Fallback correction tag: `vYYYY.M.D-N`
33+
- Use only as a last-resort recovery tag when a published immutable release burned the original stable tag and you cannot reuse it.
34+
- The npm package version stays `YYYY.M.D`; the `-N` suffix is only for the git tag and GitHub release.
35+
- Prefer betas for normal pre-release iteration, then cut a clean stable tag once ready.
3236
- Use the same version string everywhere, minus the leading `v` where Git tags are not used:
3337
- `package.json`: `2026.3.8`
3438
- Git tag: `v2026.3.8`
@@ -38,12 +42,12 @@ Current OpenClaw releases use date-based versioning.
3842
- `latest` = stable
3943
- `beta` = prerelease/testing
4044
- Dev is the moving head of `main`, not a normal git-tagged release.
41-
- The tag-triggered preview run enforces the current stable/beta tag formats and rejects versions whose CalVer date is more than 2 UTC calendar days away from the release date.
45+
- The tag-triggered preview run accepts stable, beta, and fallback correction tags, and rejects versions whose CalVer date is more than 2 UTC calendar days away from the release date.
4246

4347
Historical note:
4448

4549
- Older tags such as `v2026.1.11-1`, `v2026.2.6-3`, and `v2.0.0-beta2` exist in repo history.
46-
- Treat those as legacy tag patterns. New releases should use `vYYYY.M.D` for stable and `vYYYY.M.D-beta.N` for beta.
50+
- Treat correction tags as a fallback-only escape hatch. New releases should still use `vYYYY.M.D` for stable and `vYYYY.M.D-beta.N` for beta.
4751

4852
1. **Version & metadata**
4953

@@ -99,7 +103,9 @@ Historical note:
99103
- [ ] Run `OpenClaw NPM Release` manually with the same tag to publish after `npm-release` environment approval.
100104
- Stable tags publish to npm `latest`.
101105
- Beta tags publish to npm `beta`.
102-
- Both the preview run and the manual publish run reject tags that do not match `package.json`, are not on `main`, or whose CalVer date is more than 2 UTC calendar days away from the release date.
106+
- Fallback correction tags like `v2026.3.13-1` map to npm version `2026.3.13`.
107+
- Both the preview run and the manual publish run reject tags that do not map back to `package.json`, are not on `main`, or whose CalVer date is more than 2 UTC calendar days away from the release date.
108+
- If `[email protected]` is already published, a fallback correction tag is still useful for GitHub release and Docker recovery, but npm publish will not republish that version.
103109
- [ ] Verify the registry: `npm view openclaw version`, `npm view openclaw dist-tags`, and `npx -y [email protected] --version` (or `--help`).
104110

105111
### Troubleshooting (notes from 2.0.0-beta2 release)
@@ -109,8 +115,9 @@ Historical note:
109115
- `NPM_CONFIG_AUTH_TYPE=legacy npm dist-tag add [email protected] latest`
110116
- **`npx` verification fails with `ECOMPROMISED: Lock compromised`**: retry with a fresh cache:
111117
- `NPM_CONFIG_CACHE=/tmp/npm-cache-$(date +%s) npx -y [email protected] --version`
112-
- **Tag needs repointing after a late fix**: force-update and push the tag, then ensure the GitHub release assets still match:
113-
- `git tag -f vX.Y.Z && git push -f origin vX.Y.Z`
118+
- **Tag needs recovery after a late fix**: if the original stable tag is tied to an immutable GitHub release, mint a fallback correction tag like `vX.Y.Z-1` instead of trying to force-update `vX.Y.Z`.
119+
- Keep the npm package version at `X.Y.Z`; the correction suffix is for the git tag and GitHub release only.
120+
- Use this only as a last resort. For normal iteration, prefer beta tags and then cut a clean stable release.
114121

115122
7. **GitHub release + appcast**
116123

extensions/feishu/src/reply-dispatcher.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,4 +510,50 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
510510
}),
511511
);
512512
});
513+
514+
it("recovers streaming after start() throws (HTTP 400)", async () => {
515+
const errorMock = vi.fn();
516+
let shouldFailStart = true;
517+
518+
// Intercept streaming instance creation to make first start() reject
519+
const origPush = streamingInstances.push;
520+
streamingInstances.push = function (this: any[], ...args: any[]) {
521+
if (shouldFailStart) {
522+
args[0].start = vi
523+
.fn()
524+
.mockRejectedValue(new Error("Create card request failed with HTTP 400"));
525+
shouldFailStart = false;
526+
}
527+
return origPush.apply(this, args);
528+
} as any;
529+
530+
try {
531+
createFeishuReplyDispatcher({
532+
cfg: {} as never,
533+
agentId: "agent",
534+
runtime: { log: vi.fn(), error: errorMock } as never,
535+
chatId: "oc_chat",
536+
});
537+
538+
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
539+
540+
// First deliver with markdown triggers startStreaming - which will fail
541+
await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "block" });
542+
543+
// Wait for the async error to propagate
544+
await vi.waitFor(() => {
545+
expect(errorMock).toHaveBeenCalledWith(expect.stringContaining("streaming start failed"));
546+
});
547+
548+
// Second deliver should create a NEW streaming session (not stuck)
549+
await options.deliver({ text: "```ts\nconst y = 2\n```" }, { kind: "final" });
550+
551+
// Two instances created: first failed, second succeeded and closed
552+
expect(streamingInstances).toHaveLength(2);
553+
expect(streamingInstances[1].start).toHaveBeenCalled();
554+
expect(streamingInstances[1].close).toHaveBeenCalled();
555+
} finally {
556+
streamingInstances.push = origPush;
557+
}
558+
});
513559
});

extensions/feishu/src/reply-dispatcher.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
202202
} catch (error) {
203203
params.runtime.error?.(`feishu: streaming start failed: ${String(error)}`);
204204
streaming = null;
205+
streamingStartPromise = null; // allow retry on next deliver
205206
}
206207
})();
207208
};

extensions/signal/src/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,10 @@ function createAutoAbortController() {
7474
}
7575

7676
async function runMonitorWithMocks(opts: MonitorSignalProviderOptions) {
77-
return monitorSignalProvider(opts);
77+
return monitorSignalProvider({
78+
config: config as OpenClawConfig,
79+
...opts,
80+
});
7881
}
7982

8083
async function receiveSignalPayloads(params: {
@@ -304,7 +307,9 @@ describe("monitorSignalProvider tool results", () => {
304307
],
305308
});
306309

307-
expect(sendMock).toHaveBeenCalledTimes(1);
310+
await vi.waitFor(() => {
311+
expect(sendMock).toHaveBeenCalledTimes(1);
312+
});
308313
expect(sendMock.mock.calls[0][1]).toBe("PFX final reply");
309314
});
310315

@@ -460,8 +465,9 @@ describe("monitorSignalProvider tool results", () => {
460465
],
461466
});
462467

463-
expect(sendMock).toHaveBeenCalledTimes(1);
464-
expect(updateLastRouteMock).toHaveBeenCalled();
468+
await vi.waitFor(() => {
469+
expect(sendMock).toHaveBeenCalledTimes(1);
470+
});
465471
});
466472

467473
it("does not resend pairing code when a request is already pending", async () => {

extensions/slack/src/monitor.test-helpers.ts

Lines changed: 48 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ type SlackProviderMonitor = (params: {
55
botToken: string;
66
appToken: string;
77
abortSignal: AbortSignal;
8+
config?: Record<string, unknown>;
89
}) => Promise<unknown>;
910

1011
type SlackTestState = {
@@ -49,14 +50,51 @@ type SlackClient = {
4950
};
5051
};
5152

52-
export const getSlackHandlers = () =>
53-
(
54-
globalThis as {
55-
__slackHandlers?: Map<string, SlackHandler>;
56-
}
57-
).__slackHandlers;
53+
export const getSlackHandlers = () => ensureSlackTestRuntime().handlers;
5854

59-
export const getSlackClient = () => (globalThis as { __slackClient?: SlackClient }).__slackClient;
55+
export const getSlackClient = () => ensureSlackTestRuntime().client;
56+
57+
function ensureSlackTestRuntime(): {
58+
handlers: Map<string, SlackHandler>;
59+
client: SlackClient;
60+
} {
61+
const globalState = globalThis as {
62+
__slackHandlers?: Map<string, SlackHandler>;
63+
__slackClient?: SlackClient;
64+
};
65+
if (!globalState.__slackHandlers) {
66+
globalState.__slackHandlers = new Map<string, SlackHandler>();
67+
}
68+
if (!globalState.__slackClient) {
69+
globalState.__slackClient = {
70+
auth: { test: vi.fn().mockResolvedValue({ user_id: "bot-user" }) },
71+
conversations: {
72+
info: vi.fn().mockResolvedValue({
73+
channel: { name: "dm", is_im: true },
74+
}),
75+
replies: vi.fn().mockResolvedValue({ messages: [] }),
76+
history: vi.fn().mockResolvedValue({ messages: [] }),
77+
},
78+
users: {
79+
info: vi.fn().mockResolvedValue({
80+
user: { profile: { display_name: "Ada" } },
81+
}),
82+
},
83+
assistant: {
84+
threads: {
85+
setStatus: vi.fn().mockResolvedValue({ ok: true }),
86+
},
87+
},
88+
reactions: {
89+
add: (...args: unknown[]) => slackTestState.reactMock(...args),
90+
},
91+
};
92+
}
93+
return {
94+
handlers: globalState.__slackHandlers,
95+
client: globalState.__slackClient,
96+
};
97+
}
6098

6199
export const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
62100

@@ -78,6 +116,7 @@ export function startSlackMonitor(
78116
botToken: opts?.botToken ?? "bot-token",
79117
appToken: opts?.appToken ?? "app-token",
80118
abortSignal: controller.signal,
119+
config: slackTestState.config,
81120
});
82121
return { controller, run };
83122
}
@@ -193,34 +232,9 @@ vi.mock("../../../src/config/sessions.js", async (importOriginal) => {
193232
});
194233

195234
vi.mock("@slack/bolt", () => {
196-
const handlers = new Map<string, SlackHandler>();
197-
(globalThis as { __slackHandlers?: typeof handlers }).__slackHandlers = handlers;
198-
const client = {
199-
auth: { test: vi.fn().mockResolvedValue({ user_id: "bot-user" }) },
200-
conversations: {
201-
info: vi.fn().mockResolvedValue({
202-
channel: { name: "dm", is_im: true },
203-
}),
204-
replies: vi.fn().mockResolvedValue({ messages: [] }),
205-
history: vi.fn().mockResolvedValue({ messages: [] }),
206-
},
207-
users: {
208-
info: vi.fn().mockResolvedValue({
209-
user: { profile: { display_name: "Ada" } },
210-
}),
211-
},
212-
assistant: {
213-
threads: {
214-
setStatus: vi.fn().mockResolvedValue({ ok: true }),
215-
},
216-
},
217-
reactions: {
218-
add: (...args: unknown[]) => slackTestState.reactMock(...args),
219-
},
220-
};
221-
(globalThis as { __slackClient?: typeof client }).__slackClient = client;
235+
const { handlers, client: slackClient } = ensureSlackTestRuntime();
222236
class App {
223-
client = client;
237+
client = slackClient;
224238
event(name: string, handler: SlackHandler) {
225239
handlers.set(name, handler);
226240
}

0 commit comments

Comments
 (0)