Merge branch 'main' into vincentkoc-code/slack-block-kit-interactions

This commit is contained in:
Vincent Koc 2026-03-12 23:44:50 -04:00 committed by GitHub
commit df353a98f5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 488 additions and 48 deletions

View File

@ -84,6 +84,7 @@ Docs: https://docs.openclaw.ai
- Native chat/macOS: add `/new`, `/reset`, and `/clear` reset triggers, keep shared main-session aliases aligned, and ignore stale model-selection completions so native chat state stays in sync across reset and fast model changes. (#10898) Thanks @Nachx639.
- Agents/compaction safeguard: route missing-model and missing-API-key cancellation warnings through the shared subsystem logger so they land in structured and file logs. (#9974) Thanks @dinakars777.
- Cron/doctor: stop flagging canonical `agentTurn` and `systemEvent` payload kinds as legacy cron storage, while still normalizing whitespace-padded and non-canonical variants. (#44012) Thanks @shuicici.
- ACP/client final-message delivery: preserve terminal assistant text snapshots before resolving `end_turn`, so ACP clients no longer drop the last visible reply when the gateway sends the final message body on the terminal chat event. (#17615) Thanks @pjeby.
## 2026.3.11

View File

@ -1020,3 +1020,144 @@ describe("acp prompt size hardening", () => {
});
});
});
describe("acp final chat snapshots", () => {
async function createSnapshotHarness() {
const sessionStore = createInMemorySessionStore();
const connection = createAcpConnection();
const sessionUpdate = connection.__sessionUpdateMock;
const request = vi.fn(async (method: string) => {
if (method === "chat.send") {
return new Promise(() => {});
}
return { ok: true };
}) as GatewayClient["request"];
const agent = new AcpGatewayAgent(connection, createAcpGateway(request), {
sessionStore,
});
await agent.loadSession(createLoadSessionRequest("snapshot-session"));
sessionUpdate.mockClear();
const promptPromise = agent.prompt(createPromptRequest("snapshot-session", "hello"));
const runId = sessionStore.getSession("snapshot-session")?.activeRunId;
if (!runId) {
throw new Error("Expected ACP prompt run to be active");
}
return { agent, sessionUpdate, promptPromise, runId, sessionStore };
}
it("emits final snapshot text before resolving end_turn", async () => {
const { agent, sessionUpdate, promptPromise, runId, sessionStore } =
await createSnapshotHarness();
await agent.handleGatewayEvent({
event: "chat",
payload: {
sessionKey: "snapshot-session",
runId,
state: "final",
stopReason: "end_turn",
message: {
content: [{ type: "text", text: "FINAL TEXT SHOULD BE EMITTED" }],
},
},
} as unknown as EventFrame);
await expect(promptPromise).resolves.toEqual({ stopReason: "end_turn" });
expect(sessionUpdate).toHaveBeenCalledWith({
sessionId: "snapshot-session",
update: {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "FINAL TEXT SHOULD BE EMITTED" },
},
});
expect(sessionStore.getSession("snapshot-session")?.activeRunId).toBeNull();
sessionStore.clearAllSessionsForTest();
});
it("does not duplicate text when final repeats the last delta snapshot", async () => {
const { agent, sessionUpdate, promptPromise, runId, sessionStore } =
await createSnapshotHarness();
await agent.handleGatewayEvent({
event: "chat",
payload: {
sessionKey: "snapshot-session",
runId,
state: "delta",
message: {
content: [{ type: "text", text: "Hello world" }],
},
},
} as unknown as EventFrame);
await agent.handleGatewayEvent({
event: "chat",
payload: {
sessionKey: "snapshot-session",
runId,
state: "final",
stopReason: "end_turn",
message: {
content: [{ type: "text", text: "Hello world" }],
},
},
} as unknown as EventFrame);
await expect(promptPromise).resolves.toEqual({ stopReason: "end_turn" });
const chunks = sessionUpdate.mock.calls.filter(
(call: unknown[]) =>
(call[0] as Record<string, unknown>)?.update &&
(call[0] as Record<string, Record<string, unknown>>).update?.sessionUpdate ===
"agent_message_chunk",
);
expect(chunks).toHaveLength(1);
sessionStore.clearAllSessionsForTest();
});
it("emits only the missing tail when the final snapshot extends prior deltas", async () => {
const { agent, sessionUpdate, promptPromise, runId, sessionStore } =
await createSnapshotHarness();
await agent.handleGatewayEvent({
event: "chat",
payload: {
sessionKey: "snapshot-session",
runId,
state: "delta",
message: {
content: [{ type: "text", text: "Hello" }],
},
},
} as unknown as EventFrame);
await agent.handleGatewayEvent({
event: "chat",
payload: {
sessionKey: "snapshot-session",
runId,
state: "final",
stopReason: "max_tokens",
message: {
content: [{ type: "text", text: "Hello world" }],
},
},
} as unknown as EventFrame);
await expect(promptPromise).resolves.toEqual({ stopReason: "max_tokens" });
expect(sessionUpdate).toHaveBeenCalledWith({
sessionId: "snapshot-session",
update: {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "Hello" },
},
});
expect(sessionUpdate).toHaveBeenCalledWith({
sessionId: "snapshot-session",
update: {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: " world" },
},
});
sessionStore.clearAllSessionsForTest();
});
});

View File

@ -800,9 +800,15 @@ export class AcpGatewayAgent implements Agent {
return;
}
if (state === "delta" && messageData) {
const shouldHandleMessageSnapshot = messageData && (state === "delta" || state === "final");
if (shouldHandleMessageSnapshot) {
// Gateway chat events can carry the latest full assistant snapshot on both
// incremental updates and the terminal final event. Process the snapshot
// first so ACP clients never drop the last visible assistant text.
await this.handleDeltaEvent(pending.sessionId, messageData);
return;
if (state === "delta") {
return;
}
}
if (state === "final") {

View File

@ -54,7 +54,7 @@ async function sendSlackOutboundMessage(params: {
text: string;
mediaUrl?: string;
mediaLocalRoots?: readonly string[];
blocks?: Parameters<typeof sendMessageSlack>[2]["blocks"];
blocks?: NonNullable<Parameters<typeof sendMessageSlack>[2]>["blocks"];
accountId?: string | null;
deps?: { sendSlack?: typeof sendMessageSlack } | null;
replyToId?: string | null;

View File

@ -0,0 +1,182 @@
import { describe, expect, it } from "vitest";
import {
makeIsolatedAgentTurnJob,
makeIsolatedAgentTurnParams,
setupRunCronIsolatedAgentTurnSuite,
} from "./run.suite-helpers.js";
import {
loadRunCronIsolatedAgentTurn,
makeCronSession,
resolveCronSessionMock,
runEmbeddedPiAgentMock,
runWithModelFallbackMock,
} from "./run.test-harness.js";
const runCronIsolatedAgentTurn = await loadRunCronIsolatedAgentTurn();
describe("runCronIsolatedAgentTurn — fast mode", () => {
setupRunCronIsolatedAgentTurnSuite();
it("passes config-driven fast mode into embedded cron runs", async () => {
const cronSession = makeCronSession();
resolveCronSessionMock.mockReturnValue(cronSession);
runWithModelFallbackMock.mockImplementation(async ({ provider, model, run }) => {
await run(provider, model);
return {
result: {
payloads: [{ text: "ok" }],
meta: { agentMeta: { usage: { input: 10, output: 20 } } },
},
provider,
model,
attempts: [],
};
});
const result = await runCronIsolatedAgentTurn(
makeIsolatedAgentTurnParams({
cfg: {
agents: {
defaults: {
models: {
"openai/gpt-4": {
params: {
fastMode: true,
},
},
},
},
},
},
job: makeIsolatedAgentTurnJob({
payload: {
kind: "agentTurn",
message: "test fast mode",
model: "openai/gpt-4",
},
}),
}),
);
expect(result.status).toBe("ok");
expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce();
expect(runEmbeddedPiAgentMock.mock.calls[0][0]).toMatchObject({
provider: "openai",
model: "gpt-4",
fastMode: true,
});
});
it("honors session fastMode=false over config fastMode=true", async () => {
const cronSession = makeCronSession({
sessionEntry: {
...makeCronSession().sessionEntry,
fastMode: false,
},
});
resolveCronSessionMock.mockReturnValue(cronSession);
runWithModelFallbackMock.mockImplementation(async ({ provider, model, run }) => {
await run(provider, model);
return {
result: {
payloads: [{ text: "ok" }],
meta: { agentMeta: { usage: { input: 10, output: 20 } } },
},
provider,
model,
attempts: [],
};
});
const result = await runCronIsolatedAgentTurn(
makeIsolatedAgentTurnParams({
cfg: {
agents: {
defaults: {
models: {
"openai/gpt-4": {
params: {
fastMode: true,
},
},
},
},
},
},
job: makeIsolatedAgentTurnJob({
payload: {
kind: "agentTurn",
message: "test fast mode override",
model: "openai/gpt-4",
},
}),
}),
);
expect(result.status).toBe("ok");
expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce();
expect(runEmbeddedPiAgentMock.mock.calls[0][0]).toMatchObject({
provider: "openai",
model: "gpt-4",
fastMode: false,
});
});
it("honors session fastMode=true over config fastMode=false", async () => {
const cronSession = makeCronSession({
sessionEntry: {
...makeCronSession().sessionEntry,
fastMode: true,
},
});
resolveCronSessionMock.mockReturnValue(cronSession);
runWithModelFallbackMock.mockImplementation(async ({ provider, model, run }) => {
await run(provider, model);
return {
result: {
payloads: [{ text: "ok" }],
meta: { agentMeta: { usage: { input: 10, output: 20 } } },
},
provider,
model,
attempts: [],
};
});
const result = await runCronIsolatedAgentTurn(
makeIsolatedAgentTurnParams({
cfg: {
agents: {
defaults: {
models: {
"openai/gpt-4": {
params: {
fastMode: false,
},
},
},
},
},
},
job: makeIsolatedAgentTurnJob({
payload: {
kind: "agentTurn",
message: "test fast mode session override",
model: "openai/gpt-4",
},
}),
}),
);
expect(result.status).toBe("ok");
expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce();
expect(runEmbeddedPiAgentMock.mock.calls[0][0]).toMatchObject({
provider: "openai",
model: "gpt-4",
fastMode: true,
});
});
});

View File

@ -12,6 +12,7 @@ import { getCliSessionId, setCliSessionId } from "../../agents/cli-session.js";
import { lookupContextTokens } from "../../agents/context.js";
import { resolveCronStyleNow } from "../../agents/current-time.js";
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../../agents/defaults.js";
import { resolveFastModeState } from "../../agents/fast-mode.js";
import { resolveNestedAgentLane } from "../../agents/lanes.js";
import { loadModelCatalog } from "../../agents/model-catalog.js";
import { runWithModelFallback } from "../../agents/model-fallback.js";
@ -617,6 +618,12 @@ export async function runCronIsolatedAgentTurn(params: {
authProfileId,
authProfileIdSource,
thinkLevel,
fastMode: resolveFastModeState({
cfg: cfgWithAgentDefaults,
provider: providerOverride,
model: modelOverride,
sessionEntry: cronSession.sessionEntry,
}).enabled,
verboseLevel: resolvedVerboseLevel,
timeoutMs,
bootstrapContextMode: agentPayload?.lightContext ? "lightweight" : undefined,

View File

@ -179,6 +179,7 @@ describe("readScheduledTaskCommand", () => {
const result = await readScheduledTaskCommand(env);
expect(result).toEqual({
programArguments: ["C:/Program Files/Node/node.exe", "gateway.js"],
sourcePath: resolveTaskScriptPath(env),
});
},
);
@ -222,6 +223,7 @@ describe("readScheduledTaskCommand", () => {
NODE_ENV: "production",
OPENCLAW_PORT: "18789",
},
sourcePath: resolveTaskScriptPath(env),
});
},
);
@ -245,6 +247,7 @@ describe("readScheduledTaskCommand", () => {
"--port",
"18789",
],
sourcePath: resolveTaskScriptPath(env),
});
},
);
@ -268,6 +271,7 @@ describe("readScheduledTaskCommand", () => {
"--port",
"18789",
],
sourcePath: resolveTaskScriptPath(env),
});
},
);
@ -283,6 +287,7 @@ describe("readScheduledTaskCommand", () => {
const result = await readScheduledTaskCommand(env);
expect(result).toEqual({
programArguments: ["node", "gateway.js", "--from-state-dir"],
sourcePath: resolveTaskScriptPath(env),
});
},
);

View File

@ -96,30 +96,6 @@ const originalPath = process.env.PATH;
const originalPathExt = process.env.PATHEXT;
const originalWindowsPath = (process.env as NodeJS.ProcessEnv & { Path?: string }).Path;
async function installFakeWindowsCliPackage(params: {
rootDir: string;
packageName: "qmd" | "mcporter";
}): Promise<string> {
const nodeModulesDir = path.join(params.rootDir, "node_modules");
const shimDir = path.join(nodeModulesDir, ".bin");
const packageDir = path.join(nodeModulesDir, params.packageName);
const scriptPath = path.join(packageDir, "dist", "cli.js");
await fs.mkdir(path.dirname(scriptPath), { recursive: true });
await fs.mkdir(shimDir, { recursive: true });
await fs.writeFile(path.join(shimDir, `${params.packageName}.cmd`), "@echo off\r\n", "utf8");
await fs.writeFile(
path.join(packageDir, "package.json"),
JSON.stringify({
name: params.packageName,
version: "0.0.0",
bin: { [params.packageName]: "dist/cli.js" },
}),
"utf8",
);
await fs.writeFile(scriptPath, "module.exports = {};\n", "utf8");
return shimDir;
}
describe("QmdMemoryManager", () => {
let fixtureRoot: string;
let fixtureCount = 0;
@ -167,20 +143,9 @@ describe("QmdMemoryManager", () => {
// created lazily by manager code when needed.
await fs.mkdir(workspaceDir);
process.env.OPENCLAW_STATE_DIR = stateDir;
if (process.platform === "win32") {
const qmdShimDir = await installFakeWindowsCliPackage({
rootDir: path.join(tmpRoot, "fake-qmd-cli"),
packageName: "qmd",
});
const mcporterShimDir = await installFakeWindowsCliPackage({
rootDir: path.join(tmpRoot, "fake-mcporter-cli"),
packageName: "mcporter",
});
const nextPath = [qmdShimDir, mcporterShimDir, originalPath].filter(Boolean).join(";");
process.env.PATH = nextPath;
process.env.PATHEXT = ".CMD;.EXE";
(process.env as NodeJS.ProcessEnv & { Path?: string }).Path = nextPath;
}
// Keep the default Windows path unresolved for most tests so spawn mocks can
// match the logical package command. Tests that verify wrapper resolution
// install explicit shim fixtures inline.
cfg = {
agents: {
list: [{ id: agentId, default: true, workspace: workspaceDir }],

View File

@ -0,0 +1,65 @@
/* @vitest-environment jsdom */
import { afterEach, describe, expect, it, vi } from "vitest";
import { refreshChatAvatar, type ChatHost } from "./app-chat.ts";
function makeHost(overrides?: Partial<ChatHost>): ChatHost {
return {
client: null,
chatMessages: [],
chatStream: null,
connected: true,
chatMessage: "",
chatAttachments: [],
chatQueue: [],
chatRunId: null,
chatSending: false,
lastError: null,
sessionKey: "agent:main",
basePath: "",
hello: null,
chatAvatarUrl: null,
refreshSessionsAfterChat: new Set<string>(),
...overrides,
};
}
describe("refreshChatAvatar", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("uses a route-relative avatar endpoint before basePath bootstrap finishes", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ avatarUrl: "/avatar/main" }),
});
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
const host = makeHost({ basePath: "", sessionKey: "agent:main" });
await refreshChatAvatar(host);
expect(fetchMock).toHaveBeenCalledWith(
"avatar/main?meta=1",
expect.objectContaining({ method: "GET" }),
);
expect(host.chatAvatarUrl).toBe("/avatar/main");
});
it("keeps mounted dashboard avatar endpoints under the normalized base path", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: false,
json: async () => ({}),
});
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
const host = makeHost({ basePath: "/openclaw/", sessionKey: "agent:ops:main" });
await refreshChatAvatar(host);
expect(fetchMock).toHaveBeenCalledWith(
"/openclaw/avatar/ops?meta=1",
expect.objectContaining({ method: "GET" }),
);
expect(host.chatAvatarUrl).toBeNull();
});
});

View File

@ -372,7 +372,7 @@ function resolveAgentIdForSession(host: ChatHost): string | null {
function buildAvatarMetaUrl(basePath: string, agentId: string): string {
const base = normalizeBasePath(basePath);
const encoded = encodeURIComponent(agentId);
return base ? `${base}/avatar/${encoded}?meta=1` : `/avatar/${encoded}?meta=1`;
return base ? `${base}/avatar/${encoded}?meta=1` : `avatar/${encoded}?meta=1`;
}
export async function refreshChatAvatar(host: ChatHost) {

View File

@ -78,6 +78,7 @@ import "./components/dashboard-header.ts";
import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "./external-link.ts";
import { icons } from "./icons.ts";
import { normalizeBasePath, TAB_GROUPS, subtitleForTab, titleForTab } from "./navigation.ts";
import { agentLogoUrl } from "./views/agents-utils.ts";
import {
resolveAgentConfig,
resolveConfiguredCronModelSuggestions,
@ -450,7 +451,7 @@ export function renderApp(state: AppViewState) {
? nothing
: html`
<div class="sidebar-brand">
<img class="sidebar-brand__logo" src="${basePath ? `${basePath}/favicon.svg` : "/favicon.svg"}" alt="OpenClaw" />
<img class="sidebar-brand__logo" src="${agentLogoUrl(basePath)}" alt="OpenClaw" />
<span class="sidebar-brand__title">OpenClaw</span>
</div>
`

View File

@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
agentLogoUrl,
resolveConfiguredCronModelSuggestions,
resolveEffectiveModelFallbacks,
sortLocaleStrings,
@ -98,3 +99,14 @@ describe("sortLocaleStrings", () => {
expect(sortLocaleStrings(new Set(["beta", "alpha"]))).toEqual(["alpha", "beta"]);
});
});
describe("agentLogoUrl", () => {
it("keeps base-mounted control UI logo paths absolute to the mount", () => {
expect(agentLogoUrl("/ui")).toBe("/ui/favicon.svg");
expect(agentLogoUrl("/apps/openclaw/")).toBe("/apps/openclaw/favicon.svg");
});
it("uses a route-relative fallback before basePath bootstrap finishes", () => {
expect(agentLogoUrl("")).toBe("favicon.svg");
});
});

View File

@ -215,7 +215,7 @@ export function resolveAgentAvatarUrl(
export function agentLogoUrl(basePath: string): string {
const base = basePath?.trim() ? basePath.replace(/\/$/, "") : "";
return base ? `${base}/favicon.svg` : "/favicon.svg";
return base ? `${base}/favicon.svg` : "favicon.svg";
}
function isLikelyEmoji(value: string) {

View File

@ -1,3 +1,5 @@
/* @vitest-environment jsdom */
import { render } from "lit";
import { describe, expect, it, vi } from "vitest";
import type { SessionsListResult } from "../types.ts";
@ -54,6 +56,46 @@ function createProps(overrides: Partial<ChatProps> = {}): ChatProps {
}
describe("chat view", () => {
it("uses the assistant avatar URL for the welcome state when the identity avatar is only initials", () => {
const container = document.createElement("div");
render(
renderChat(
createProps({
assistantName: "Assistant",
assistantAvatar: "A",
assistantAvatarUrl: "/avatar/main",
}),
),
container,
);
const welcomeImage = container.querySelector<HTMLImageElement>(".agent-chat__welcome > img");
expect(welcomeImage).not.toBeNull();
expect(welcomeImage?.getAttribute("src")).toBe("/avatar/main");
});
it("falls back to the bundled logo in the welcome state when the assistant avatar is not a URL", () => {
const container = document.createElement("div");
render(
renderChat(
createProps({
assistantName: "Assistant",
assistantAvatar: "A",
assistantAvatarUrl: null,
}),
),
container,
);
const welcomeImage = container.querySelector<HTMLImageElement>(".agent-chat__welcome > img");
const logoImage = container.querySelector<HTMLImageElement>(
".agent-chat__welcome .agent-chat__avatar--logo img",
);
expect(welcomeImage).toBeNull();
expect(logoImage).not.toBeNull();
expect(logoImage?.getAttribute("src")).toBe("favicon.svg");
});
it("renders compacting indicator as a badge", () => {
const container = document.createElement("div");
render(

View File

@ -31,7 +31,7 @@ import { detectTextDirection } from "../text-direction.ts";
import type { GatewaySessionRow, SessionsListResult } from "../types.ts";
import type { ChatItem, MessageGroup } from "../types/chat-types.ts";
import type { ChatAttachment, ChatQueueItem } from "../ui-types.ts";
import { agentLogoUrl } from "./agents-utils.ts";
import { agentLogoUrl, resolveAgentAvatarUrl } from "./agents-utils.ts";
import { renderMarkdownSidebar } from "./markdown-sidebar.ts";
import "../components/resizable-divider.ts";
@ -566,7 +566,12 @@ const WELCOME_SUGGESTIONS = [
function renderWelcomeState(props: ChatProps): TemplateResult {
const name = props.assistantName || "Assistant";
const avatar = props.assistantAvatar ?? props.assistantAvatarUrl;
const avatar = resolveAgentAvatarUrl({
identity: {
avatar: props.assistantAvatar ?? undefined,
avatarUrl: props.assistantAvatarUrl ?? undefined,
},
});
const logoUrl = agentLogoUrl(props.basePath ?? "");
return html`
@ -802,7 +807,13 @@ export function renderChat(props: ChatProps) {
const showReasoning = props.showThinking && reasoningLevel !== "off";
const assistantIdentity = {
name: props.assistantName,
avatar: props.assistantAvatar ?? props.assistantAvatarUrl ?? null,
avatar:
resolveAgentAvatarUrl({
identity: {
avatar: props.assistantAvatar ?? undefined,
avatarUrl: props.assistantAvatarUrl ?? undefined,
},
}) ?? null,
};
const pinned = getPinnedMessages(props.sessionKey);
const deleted = getDeletedMessages(props.sessionKey);

View File

@ -4,10 +4,11 @@ import { renderThemeToggle } from "../app-render.helpers.ts";
import type { AppViewState } from "../app-view-state.ts";
import { icons } from "../icons.ts";
import { normalizeBasePath } from "../navigation.ts";
import { agentLogoUrl } from "./agents-utils.ts";
export function renderLoginGate(state: AppViewState) {
const basePath = normalizeBasePath(state.basePath ?? "");
const faviconSrc = basePath ? `${basePath}/favicon.svg` : "/favicon.svg";
const faviconSrc = agentLogoUrl(basePath);
return html`
<div class="login-gate">

View File

@ -83,6 +83,7 @@ export default defineConfig({
"extensions/**/*.test.ts",
"test/**/*.test.ts",
"ui/src/ui/views/agents-utils.test.ts",
"ui/src/ui/views/chat.test.ts",
"ui/src/ui/views/usage-render-details.test.ts",
"ui/src/ui/controllers/agents.test.ts",
"ui/src/ui/controllers/chat.test.ts",