Merge branch 'main' into vincentkoc-code/slack-block-kit-interactions
This commit is contained in:
commit
df353a98f5
@ -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
|
||||
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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") {
|
||||
|
||||
@ -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;
|
||||
|
||||
182
src/cron/isolated-agent/run.fast-mode.test.ts
Normal file
182
src/cron/isolated-agent/run.fast-mode.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
|
||||
@ -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),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@ -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 }],
|
||||
|
||||
65
ui/src/ui/app-chat.test.ts
Normal file
65
ui/src/ui/app-chat.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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) {
|
||||
|
||||
@ -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>
|
||||
`
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user