fix(ui): add agent selector to dreaming tab (#78748)

Fixes #63558.

Adds a Dreaming-tab agent selector and propagates the selected agent through Dreaming status, diary, and diary actions while preserving default-agent fallback when agentId is omitted. Also keeps report Memory Palace cards in the Control UI wiki-preview flow and documents the optional Dreaming agentId gateway parameters.

Verification:
- GitHub CI run 26693682975 passed on 43a2b17243.
- CodeQL Critical Quality run 26693682971 passed.
- CodeQL / Security High run 26693682957 passed.
- Workflow Sanity run 26693682949 passed.
- OpenGrep PR Diff run 26693682947 passed.
- Dependency Guard run 26693682003 passed.
- Real behavior proof run 26693860539 passed.
- git diff --check origin/main...refs/remotes/origin/pr/78748 passed.
- git merge-tree --write-tree origin/main refs/remotes/origin/pr/78748 passed.

Thanks @stevenepalmer.

Co-authored-by: Steven Palmer <6134396+stevenepalmer@users.noreply.github.com>
This commit is contained in:
Steven
2026-05-30 14:58:00 -07:00
committed by GitHub
parent d93394e29b
commit cd3d960ec5
52 changed files with 842 additions and 159 deletions
+2 -1
View File
@@ -348,7 +348,8 @@ enumeration of `src/gateway/server-methods/*.ts`.
- `usage.status` returns provider usage windows/remaining quota summaries.
- `usage.cost` returns aggregated cost usage summaries for a date range.
Pass `agentId` for one agent, or `agentScope: "all"` to aggregate configured agents.
- `doctor.memory.status` returns vector-memory / cached embedding readiness for the active default agent workspace. Pass `{ "probe": true }` or `{ "deep": true }` only when the caller explicitly wants a live embedding provider ping.
- `doctor.memory.status` returns vector-memory / cached embedding readiness for the active default agent workspace. Pass `{ "probe": true }` or `{ "deep": true }` only when the caller explicitly wants a live embedding provider ping. Dreaming-aware clients may also pass `{ "agentId": "agent-id" }` to scope Dreaming store stats to a selected agent workspace; omitting `agentId` keeps the default-agent fallback and aggregates configured Dreaming workspaces.
- `doctor.memory.dreamDiary`, `doctor.memory.backfillDreamDiary`, `doctor.memory.resetDreamDiary`, `doctor.memory.resetGroundedShortTerm`, `doctor.memory.repairDreamingArtifacts`, and `doctor.memory.dedupeDreamDiary` accept optional `{ "agentId": "agent-id" }` params for selected-agent Dreaming views/actions. When `agentId` is omitted, they operate on the configured default agent workspace.
- `doctor.memory.remHarness` returns a bounded, read-only REM harness preview for remote control-plane clients. It can include workspace paths, memory snippets, rendered grounded markdown, and deep promotion candidates, so callers need `operator.read`.
- `sessions.usage` returns per-session usage summaries. Pass `agentId` for one
agent, or `agentScope: "all"` to list configured agents together.
+44 -6
View File
@@ -412,16 +412,21 @@ async function allocateLoopbackPort() {
}
function buildTimedWatchCommand(pidFilePath, timeFilePath, isolatedHomeDir, port) {
const isolatedStateDir = path.join(isolatedHomeDir, ".openclaw");
const isolatedConfigPath = path.join(isolatedStateDir, "openclaw.json");
const shellSource = [
'echo "$$" > "$OPENCLAW_WATCH_PID_FILE"',
'mkdir -p "$OPENCLAW_HOME/.openclaw"',
`printf '%s\n' '{"gateway":{"controlUi":{"enabled":false}},"plugins":{"enabled":false}}' > "$OPENCLAW_HOME/.openclaw/openclaw.json"`,
'mkdir -p "$OPENCLAW_STATE_DIR"',
`printf '%s\n' '{"gateway":{"controlUi":{"enabled":false}},"plugins":{"enabled":false}}' > "$OPENCLAW_CONFIG_PATH"`,
`exec node scripts/watch-node.mjs gateway --force --allow-unconfigured --port ${String(port)} --token watch-regression-token`,
].join("\n");
const env = {
OPENCLAW_WATCH_PID_FILE: pidFilePath,
HOME: isolatedHomeDir,
OPENCLAW_HOME: isolatedHomeDir,
OPENCLAW_CONFIG_PATH: isolatedConfigPath,
OPENCLAW_STATE_DIR: isolatedStateDir,
XDG_CONFIG_HOME: path.join(isolatedHomeDir, ".config"),
...WATCH_GATEWAY_SKIP_ENV,
};
@@ -433,6 +438,14 @@ function buildTimedWatchCommand(pidFilePath, timeFilePath, isolatedHomeDir, port
};
}
if (!fs.existsSync("/usr/bin/time")) {
return {
command: "/bin/sh",
args: ["-lc", shellSource],
env,
};
}
return {
command: "/usr/bin/time",
args: [
@@ -511,6 +524,14 @@ async function runTimedWatch(options, outputDir) {
buildDetection = updateWatchBuildDetection(buildDetection, chunk);
});
let spawnError = null;
const spawnErrorExit = new Promise((resolve) => {
child.once("error", (error) => {
spawnError = error;
resolve({ code: null, signal: null, error: error.message });
});
});
let watchPid = null;
for (let attempt = 0; attempt < 50; attempt += 1) {
if (fs.existsSync(pidFilePath)) {
@@ -531,15 +552,18 @@ async function runTimedWatch(options, outputDir) {
await sleep(options.windowMs);
const idleCpuEndMs = watchPid ? readProcessTreeCpuMs(watchPid) : null;
const exit = await stopTimedWatchChild(child, watchPid, options);
const exit = await Promise.race([stopTimedWatchChild(child, watchPid, options), spawnErrorExit]);
fs.writeFileSync(stdoutPath, formatCapturedWatchLog(stdout, stdoutTruncated), "utf8");
fs.writeFileSync(stderrPath, formatCapturedWatchLog(stderr, stderrTruncated), "utf8");
const timing = fs.existsSync(timeFilePath)
? parseTimingFile(timeFilePath)
: { userSeconds: Number.NaN, sysSeconds: Number.NaN, elapsedSeconds: Number.NaN };
const timingFileMissing = !fs.existsSync(timeFilePath);
const timing = timingFileMissing
? { userSeconds: Number.NaN, sysSeconds: Number.NaN, elapsedSeconds: Number.NaN }
: parseTimingFile(timeFilePath);
return {
exit,
spawnError: spawnError ? spawnError.message : null,
timingFileMissing,
timing,
readyBeforeWindow,
idleCpuMs:
@@ -768,6 +792,8 @@ async function main() {
addedPaths: diff.added.length,
removedPaths: diff.removed.length,
watchExit: watchResult.exit,
spawnError: watchResult.spawnError,
timingFileMissing: watchResult.timingFileMissing,
timing: watchResult.timing,
};
fs.writeFileSync(
@@ -779,6 +805,18 @@ async function main() {
const failures = [];
const warnings = [];
if (watchResult.spawnError) {
failures.push(`gateway:watch failed to start: ${watchResult.spawnError}`);
}
if (watchResult.timingFileMissing && !Number.isFinite(watchResult.idleCpuMs)) {
failures.push(
"failed to collect CPU timing from the bounded gateway:watch run; timing artifact is missing",
);
} else if (watchResult.timingFileMissing) {
warnings.push(
"bounded gateway:watch timing artifact is missing; using process-tree idle CPU sample",
);
}
if (watchTriggeredBuild && watchBuildReason === "dirty_watched_tree") {
failures.push(
"gateway:watch invalid local run: dirty watched source tree forced a rebuild during the watch window",
+129 -2
View File
@@ -78,10 +78,13 @@ const invokeDoctorMemoryStatus = async (
});
};
const invokeDoctorMemoryDreamDiary = async (respond: ReturnType<typeof vi.fn>) => {
const invokeDoctorMemoryDreamDiary = async (
respond: ReturnType<typeof vi.fn>,
params: unknown = {},
) => {
await doctorHandlers["doctor.memory.dreamDiary"]({
req: {} as never,
params: {} as never,
params: params as never,
respond: respond as never,
context: makeRuntimeContext() as never,
client: null,
@@ -263,6 +266,33 @@ describe("doctor.memory.status", () => {
expect(close).toHaveBeenCalled();
});
it("returns gateway embedding probe status for the requested agent", async () => {
const close = vi.fn().mockResolvedValue(undefined);
getMemorySearchManager.mockResolvedValue({
manager: {
status: () => ({ provider: "gemini", workspaceDir: "/tmp/research-workspace" }),
probeEmbeddingAvailability: vi.fn().mockResolvedValue({ ok: true }),
close,
},
});
const respond = vi.fn();
await invokeDoctorMemoryStatus(respond, {
params: { agentId: "research-analyst", probe: true },
});
expectRecordFields(mockCallArg(getMemorySearchManager), {
agentId: "research-analyst",
purpose: "status",
});
const payload = respondPayload(respond);
expectRecordFields(payload, {
agentId: "research-analyst",
provider: "gemini",
embedding: { ok: true },
});
});
it("does not live-probe embedding readiness by default", async () => {
const close = vi.fn().mockResolvedValue(undefined);
const probeEmbeddingAvailability = vi.fn().mockResolvedValue({ ok: true });
@@ -609,6 +639,80 @@ describe("doctor.memory.status", () => {
}
});
it("scopes dreaming status to the requested agent workspace", async () => {
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "doctor-memory-selected-"));
const mainWorkspaceDir = path.join(workspaceRoot, "main");
const alphaWorkspaceDir = path.join(workspaceRoot, "alpha");
const writeStore = async (workspaceDir: string, snippet: string) => {
const storePath = path.join(workspaceDir, "memory", ".dreams", "short-term-recall.json");
await fs.mkdir(path.dirname(storePath), { recursive: true });
const store = {
version: 1,
updatedAt: "2026-04-04T00:00:00.000Z",
entries: {
"memory:memory/2026-04-04.md:1:2": {
path: "memory/2026-04-04.md",
startLine: 1,
endLine: 2,
snippet,
source: "memory",
promotedAt: "2026-04-04T00:00:00.000Z",
},
},
};
await fs.writeFile(storePath, JSON.stringify(store, null, 2) + "\n", "utf-8");
};
await writeStore(mainWorkspaceDir, "main agent memory");
await writeStore(alphaWorkspaceDir, "alpha agent memory");
getRuntimeConfig.mockReturnValue({
agents: {
list: [{ id: "alpha", workspace: alphaWorkspaceDir }],
},
plugins: {
entries: {
"memory-core": {
config: {
dreaming: {},
},
},
},
},
} as OpenClawConfig);
resolveAgentWorkspaceDir.mockImplementation((_cfg: OpenClawConfig, agentId: string) => {
if (agentId === "alpha") {
return alphaWorkspaceDir;
}
return mainWorkspaceDir;
});
const close = vi.fn().mockResolvedValue(undefined);
getMemorySearchManager.mockResolvedValue({
manager: {
status: () => ({ provider: "gemini", workspaceDir: alphaWorkspaceDir }),
probeEmbeddingAvailability: vi.fn().mockResolvedValue({ ok: true }),
close,
},
});
const respond = vi.fn();
try {
await invokeDoctorMemoryStatus(respond, { params: { agentId: "alpha" } });
const payload = respondPayload(respond);
expectRecordFields(payload, {
agentId: "alpha",
});
const dreaming = expectRecordFields(payload.dreaming, {
shortTermCount: 0,
promotedTotal: 1,
});
expectRecordFields((dreaming.promotedEntries as unknown[])[0], {
snippet: "alpha agent memory",
});
} finally {
await fs.rm(workspaceRoot, { recursive: true, force: true });
}
});
it("falls back to the manager workspace when no configured dreaming workspaces resolve", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "doctor-memory-fallback-"));
const storePath = path.join(workspaceDir, "memory", ".dreams", "short-term-recall.json");
@@ -939,6 +1043,29 @@ describe("doctor.memory.dreamDiary", () => {
}
});
it("reads DREAMS.md for the requested agent", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "doctor-dream-diary-agent-"));
await fs.writeFile(path.join(workspaceDir, "DREAMS.md"), "## Research Dreams\n", "utf-8");
resolveAgentWorkspaceDir.mockImplementation((_cfg, agentId) =>
agentId === "research-analyst" ? workspaceDir : "/tmp/openclaw",
);
const respond = vi.fn();
try {
await invokeDoctorMemoryDreamDiary(respond, { agentId: "research-analyst" });
expect(resolveAgentWorkspaceDir).toHaveBeenCalledWith(expect.anything(), "research-analyst");
const payload = respondPayload(respond);
expectRecordFields(payload, {
agentId: "research-analyst",
found: true,
path: "DREAMS.md",
content: "## Research Dreams\n",
});
} finally {
await fs.rm(workspaceDir, { recursive: true, force: true });
}
});
it("reads lowercase dreams.md when present", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "doctor-dream-diary-lower-"));
await fs.writeFile(path.join(workspaceDir, "dreams.md"), "lowercase diary\n", "utf-8");
+36 -17
View File
@@ -12,6 +12,7 @@ import {
resolveMemoryRemDreamingConfig,
} from "../../memory-host-sdk/dreaming.js";
import { getActiveMemorySearchManager } from "../../plugins/memory-runtime.js";
import { normalizeAgentId } from "../../routing/session-key.js";
import { formatError } from "../server-utils.js";
import {
dedupeDreamDiaryEntries,
@@ -893,7 +894,9 @@ const SKIPPED_MEMORY_EMBEDDING_PROBE = {
export const doctorHandlers: GatewayRequestHandlers = {
"doctor.memory.status": async ({ respond, context, params }) => {
const cfg = context.getRuntimeConfig();
const agentId = resolveDefaultAgentId(cfg);
const requestedAgentId =
typeof params?.agentId === "string" ? normalizeAgentId(params.agentId) : null;
const agentId = requestedAgentId || resolveDefaultAgentId(cfg);
const { manager, error } = await getActiveMemorySearchManager({
cfg,
agentId,
@@ -923,10 +926,14 @@ export const doctorHandlers: GatewayRequestHandlers = {
const nowMs = Date.now();
const dreamingConfig = resolveDreamingConfig(cfg);
const workspaceDir = normalizeTrimmedString((status as Record<string, unknown>).workspaceDir);
const configuredWorkspaces = resolveMemoryDreamingWorkspaces(cfg, {
primaryWorkspaceDir: workspaceDir,
primaryAgentId: resolveDefaultAgentId(cfg),
}).map((entry) => entry.workspaceDir);
const configuredWorkspaces = requestedAgentId
? workspaceDir
? [workspaceDir]
: []
: resolveMemoryDreamingWorkspaces(cfg, {
primaryWorkspaceDir: workspaceDir,
primaryAgentId: agentId,
}).map((entry) => entry.workspaceDir);
const allWorkspaces =
configuredWorkspaces.length > 0 ? configuredWorkspaces : workspaceDir ? [workspaceDir] : [];
const storeStats =
@@ -988,9 +995,11 @@ export const doctorHandlers: GatewayRequestHandlers = {
await manager.close?.().catch(() => {});
}
},
"doctor.memory.dreamDiary": async ({ respond, context }) => {
"doctor.memory.dreamDiary": async ({ respond, context, params }) => {
const cfg = context.getRuntimeConfig();
const agentId = resolveDefaultAgentId(cfg);
const requestedAgentId =
typeof params?.agentId === "string" ? normalizeAgentId(params.agentId) : null;
const agentId = requestedAgentId || resolveDefaultAgentId(cfg);
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
const dreamDiary = await readDreamDiary(workspaceDir);
const payload: DoctorMemoryDreamDiaryPayload = {
@@ -999,9 +1008,11 @@ export const doctorHandlers: GatewayRequestHandlers = {
};
respond(true, payload, undefined);
},
"doctor.memory.backfillDreamDiary": async ({ respond, context }) => {
"doctor.memory.backfillDreamDiary": async ({ respond, context, params }) => {
const cfg = context.getRuntimeConfig();
const agentId = resolveDefaultAgentId(cfg);
const requestedAgentId =
typeof params?.agentId === "string" ? normalizeAgentId(params.agentId) : null;
const agentId = requestedAgentId || resolveDefaultAgentId(cfg);
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
const memoryDir = path.join(workspaceDir, "memory");
const sourceFiles = await listWorkspaceDailyFiles(memoryDir);
@@ -1057,9 +1068,11 @@ export const doctorHandlers: GatewayRequestHandlers = {
};
respond(true, payload, undefined);
},
"doctor.memory.resetDreamDiary": async ({ respond, context }) => {
"doctor.memory.resetDreamDiary": async ({ respond, context, params }) => {
const cfg = context.getRuntimeConfig();
const agentId = resolveDefaultAgentId(cfg);
const requestedAgentId =
typeof params?.agentId === "string" ? normalizeAgentId(params.agentId) : null;
const agentId = requestedAgentId || resolveDefaultAgentId(cfg);
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
const removed = await removeBackfillDiaryEntries({ workspaceDir });
const dreamDiary = await readDreamDiary(workspaceDir);
@@ -1072,9 +1085,11 @@ export const doctorHandlers: GatewayRequestHandlers = {
};
respond(true, payload, undefined);
},
"doctor.memory.resetGroundedShortTerm": async ({ respond, context }) => {
"doctor.memory.resetGroundedShortTerm": async ({ respond, context, params }) => {
const cfg = context.getRuntimeConfig();
const agentId = resolveDefaultAgentId(cfg);
const requestedAgentId =
typeof params?.agentId === "string" ? normalizeAgentId(params.agentId) : null;
const agentId = requestedAgentId || resolveDefaultAgentId(cfg);
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
const removed = await removeGroundedShortTermCandidates({ workspaceDir });
const payload: DoctorMemoryDreamActionPayload = {
@@ -1084,9 +1099,11 @@ export const doctorHandlers: GatewayRequestHandlers = {
};
respond(true, payload, undefined);
},
"doctor.memory.repairDreamingArtifacts": async ({ respond, context }) => {
"doctor.memory.repairDreamingArtifacts": async ({ respond, context, params }) => {
const cfg = context.getRuntimeConfig();
const agentId = resolveDefaultAgentId(cfg);
const requestedAgentId =
typeof params?.agentId === "string" ? normalizeAgentId(params.agentId) : null;
const agentId = requestedAgentId || resolveDefaultAgentId(cfg);
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
const repair = await repairDreamingArtifacts({ workspaceDir });
const payload: DoctorMemoryDreamActionPayload = {
@@ -1101,9 +1118,11 @@ export const doctorHandlers: GatewayRequestHandlers = {
};
respond(true, payload, undefined);
},
"doctor.memory.dedupeDreamDiary": async ({ respond, context }) => {
"doctor.memory.dedupeDreamDiary": async ({ respond, context, params }) => {
const cfg = context.getRuntimeConfig();
const agentId = resolveDefaultAgentId(cfg);
const requestedAgentId =
typeof params?.agentId === "string" ? normalizeAgentId(params.agentId) : null;
const agentId = requestedAgentId || resolveDefaultAgentId(cfg);
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
const dedupe = await dedupeDreamDiaryEntries({ workspaceDir });
const dreamDiary = await readDreamDiary(workspaceDir);
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-30T15:38:27.122Z",
"generatedAt": "2026-05-30T19:58:53.789Z",
"locale": "ar",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "d2195849e06087f13cb1c7ff6afd46aa15649304779e3cbfe6ab704c603693a7",
"totalKeys": 1287,
"translatedKeys": 1287,
"sourceHash": "6a593959229f82b0f1b312c7d387f1095137a71067734613f0ef98d8085dea5a",
"totalKeys": 1289,
"translatedKeys": 1289,
"workflow": 1
}
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-30T15:38:11.512Z",
"generatedAt": "2026-05-30T19:58:50.267Z",
"locale": "de",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "d2195849e06087f13cb1c7ff6afd46aa15649304779e3cbfe6ab704c603693a7",
"totalKeys": 1287,
"translatedKeys": 1287,
"sourceHash": "6a593959229f82b0f1b312c7d387f1095137a71067734613f0ef98d8085dea5a",
"totalKeys": 1289,
"translatedKeys": 1289,
"workflow": 1
}
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-30T15:38:14.091Z",
"generatedAt": "2026-05-30T19:58:50.968Z",
"locale": "es",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "d2195849e06087f13cb1c7ff6afd46aa15649304779e3cbfe6ab704c603693a7",
"totalKeys": 1287,
"translatedKeys": 1287,
"sourceHash": "6a593959229f82b0f1b312c7d387f1095137a71067734613f0ef98d8085dea5a",
"totalKeys": 1289,
"translatedKeys": 1289,
"workflow": 1
}
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-30T15:38:53.299Z",
"generatedAt": "2026-05-30T19:59:00.128Z",
"locale": "fa",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "d2195849e06087f13cb1c7ff6afd46aa15649304779e3cbfe6ab704c603693a7",
"totalKeys": 1287,
"translatedKeys": 1287,
"sourceHash": "6a593959229f82b0f1b312c7d387f1095137a71067734613f0ef98d8085dea5a",
"totalKeys": 1289,
"translatedKeys": 1289,
"workflow": 1
}
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-30T15:38:24.002Z",
"generatedAt": "2026-05-30T19:58:53.086Z",
"locale": "fr",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "d2195849e06087f13cb1c7ff6afd46aa15649304779e3cbfe6ab704c603693a7",
"totalKeys": 1287,
"translatedKeys": 1287,
"sourceHash": "6a593959229f82b0f1b312c7d387f1095137a71067734613f0ef98d8085dea5a",
"totalKeys": 1289,
"translatedKeys": 1289,
"workflow": 1
}
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-30T15:38:39.713Z",
"generatedAt": "2026-05-30T19:58:56.596Z",
"locale": "id",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "d2195849e06087f13cb1c7ff6afd46aa15649304779e3cbfe6ab704c603693a7",
"totalKeys": 1287,
"translatedKeys": 1287,
"sourceHash": "6a593959229f82b0f1b312c7d387f1095137a71067734613f0ef98d8085dea5a",
"totalKeys": 1289,
"translatedKeys": 1289,
"workflow": 1
}
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-30T15:38:31.726Z",
"generatedAt": "2026-05-30T19:58:54.491Z",
"locale": "it",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "d2195849e06087f13cb1c7ff6afd46aa15649304779e3cbfe6ab704c603693a7",
"totalKeys": 1287,
"translatedKeys": 1287,
"sourceHash": "6a593959229f82b0f1b312c7d387f1095137a71067734613f0ef98d8085dea5a",
"totalKeys": 1289,
"translatedKeys": 1289,
"workflow": 1
}
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-30T15:38:16.651Z",
"generatedAt": "2026-05-30T19:58:51.672Z",
"locale": "ja-JP",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "d2195849e06087f13cb1c7ff6afd46aa15649304779e3cbfe6ab704c603693a7",
"totalKeys": 1287,
"translatedKeys": 1287,
"sourceHash": "6a593959229f82b0f1b312c7d387f1095137a71067734613f0ef98d8085dea5a",
"totalKeys": 1289,
"translatedKeys": 1289,
"workflow": 1
}
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-30T15:38:20.922Z",
"generatedAt": "2026-05-30T19:58:52.390Z",
"locale": "ko",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "d2195849e06087f13cb1c7ff6afd46aa15649304779e3cbfe6ab704c603693a7",
"totalKeys": 1287,
"translatedKeys": 1287,
"sourceHash": "6a593959229f82b0f1b312c7d387f1095137a71067734613f0ef98d8085dea5a",
"totalKeys": 1289,
"translatedKeys": 1289,
"workflow": 1
}
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-30T15:38:50.261Z",
"generatedAt": "2026-05-30T19:58:59.409Z",
"locale": "nl",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "d2195849e06087f13cb1c7ff6afd46aa15649304779e3cbfe6ab704c603693a7",
"totalKeys": 1287,
"translatedKeys": 1287,
"sourceHash": "6a593959229f82b0f1b312c7d387f1095137a71067734613f0ef98d8085dea5a",
"totalKeys": 1289,
"translatedKeys": 1289,
"workflow": 1
}
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-30T15:38:42.058Z",
"generatedAt": "2026-05-30T19:58:57.290Z",
"locale": "pl",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "d2195849e06087f13cb1c7ff6afd46aa15649304779e3cbfe6ab704c603693a7",
"totalKeys": 1287,
"translatedKeys": 1287,
"sourceHash": "6a593959229f82b0f1b312c7d387f1095137a71067734613f0ef98d8085dea5a",
"totalKeys": 1289,
"translatedKeys": 1289,
"workflow": 1
}
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-30T15:38:08.361Z",
"generatedAt": "2026-05-30T19:58:49.558Z",
"locale": "pt-BR",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "d2195849e06087f13cb1c7ff6afd46aa15649304779e3cbfe6ab704c603693a7",
"totalKeys": 1287,
"translatedKeys": 1287,
"sourceHash": "6a593959229f82b0f1b312c7d387f1095137a71067734613f0ef98d8085dea5a",
"totalKeys": 1289,
"translatedKeys": 1289,
"workflow": 1
}
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-30T15:38:44.768Z",
"generatedAt": "2026-05-30T19:58:57.996Z",
"locale": "th",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "d2195849e06087f13cb1c7ff6afd46aa15649304779e3cbfe6ab704c603693a7",
"totalKeys": 1287,
"translatedKeys": 1287,
"sourceHash": "6a593959229f82b0f1b312c7d387f1095137a71067734613f0ef98d8085dea5a",
"totalKeys": 1289,
"translatedKeys": 1289,
"workflow": 1
}
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-30T15:38:34.872Z",
"generatedAt": "2026-05-30T19:58:55.193Z",
"locale": "tr",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "d2195849e06087f13cb1c7ff6afd46aa15649304779e3cbfe6ab704c603693a7",
"totalKeys": 1287,
"translatedKeys": 1287,
"sourceHash": "6a593959229f82b0f1b312c7d387f1095137a71067734613f0ef98d8085dea5a",
"totalKeys": 1289,
"translatedKeys": 1289,
"workflow": 1
}
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-30T15:38:37.448Z",
"generatedAt": "2026-05-30T19:58:55.895Z",
"locale": "uk",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "d2195849e06087f13cb1c7ff6afd46aa15649304779e3cbfe6ab704c603693a7",
"totalKeys": 1287,
"translatedKeys": 1287,
"sourceHash": "6a593959229f82b0f1b312c7d387f1095137a71067734613f0ef98d8085dea5a",
"totalKeys": 1289,
"translatedKeys": 1289,
"workflow": 1
}
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-30T15:38:47.757Z",
"generatedAt": "2026-05-30T19:58:58.704Z",
"locale": "vi",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "d2195849e06087f13cb1c7ff6afd46aa15649304779e3cbfe6ab704c603693a7",
"totalKeys": 1287,
"translatedKeys": 1287,
"sourceHash": "6a593959229f82b0f1b312c7d387f1095137a71067734613f0ef98d8085dea5a",
"totalKeys": 1289,
"translatedKeys": 1289,
"workflow": 1
}
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-30T15:38:03.159Z",
"generatedAt": "2026-05-30T19:58:48.092Z",
"locale": "zh-CN",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "d2195849e06087f13cb1c7ff6afd46aa15649304779e3cbfe6ab704c603693a7",
"totalKeys": 1287,
"translatedKeys": 1287,
"sourceHash": "6a593959229f82b0f1b312c7d387f1095137a71067734613f0ef98d8085dea5a",
"totalKeys": 1289,
"translatedKeys": 1289,
"workflow": 1
}
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-30T15:38:05.611Z",
"generatedAt": "2026-05-30T19:58:48.848Z",
"locale": "zh-TW",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "d2195849e06087f13cb1c7ff6afd46aa15649304779e3cbfe6ab704c603693a7",
"totalKeys": 1287,
"translatedKeys": 1287,
"sourceHash": "6a593959229f82b0f1b312c7d387f1095137a71067734613f0ef98d8085dea5a",
"totalKeys": 1289,
"translatedKeys": 1289,
"workflow": 1
}
+4
View File
@@ -753,6 +753,10 @@ export const ar: TranslationMap = {
},
},
dreaming: {
agentSelect: {
label: "الوكيل",
ariaLabel: "Dreaming agent",
},
tabs: {
scene: "المشهد",
diary: "اليوميات",
+4
View File
@@ -765,6 +765,10 @@ export const de: TranslationMap = {
},
},
dreaming: {
agentSelect: {
label: "Agent",
ariaLabel: "Dreaming agent",
},
tabs: {
scene: "Szene",
diary: "Tagebuch",
+4
View File
@@ -755,6 +755,10 @@ export const en: TranslationMap = {
},
},
dreaming: {
agentSelect: {
label: "Agent",
ariaLabel: "Dreaming agent",
},
tabs: {
scene: "Scene",
diary: "Diary",
+4
View File
@@ -759,6 +759,10 @@ export const es: TranslationMap = {
},
},
dreaming: {
agentSelect: {
label: "Agente",
ariaLabel: "Dreaming agent",
},
tabs: {
scene: "Escena",
diary: "Diario",
+4
View File
@@ -760,6 +760,10 @@ export const fa: TranslationMap = {
},
},
dreaming: {
agentSelect: {
label: "عامل",
ariaLabel: "Dreaming agent",
},
tabs: {
scene: "صحنه",
diary: "دفترچه",
+4
View File
@@ -763,6 +763,10 @@ export const fr: TranslationMap = {
},
},
dreaming: {
agentSelect: {
label: "Agent",
ariaLabel: "Dreaming agent",
},
tabs: {
scene: "Scène",
diary: "Journal",
+4
View File
@@ -759,6 +759,10 @@ export const id: TranslationMap = {
},
},
dreaming: {
agentSelect: {
label: "Agen",
ariaLabel: "Dreaming agent",
},
tabs: {
scene: "Scene",
diary: "Diary",
+4
View File
@@ -761,6 +761,10 @@ export const it: TranslationMap = {
},
},
dreaming: {
agentSelect: {
label: "Agente",
ariaLabel: "Dreaming agent",
},
tabs: {
scene: "Scena",
diary: "Diario",
+4
View File
@@ -763,6 +763,10 @@ export const ja_JP: TranslationMap = {
},
},
dreaming: {
agentSelect: {
label: "エージェント",
ariaLabel: "Dreaming agent",
},
tabs: {
scene: "Scene",
diary: "Diary",
+4
View File
@@ -758,6 +758,10 @@ export const ko: TranslationMap = {
},
},
dreaming: {
agentSelect: {
label: "에이전트",
ariaLabel: "Dreaming agent",
},
tabs: {
scene: "장면",
diary: "일지",
+4
View File
@@ -762,6 +762,10 @@ export const nl: TranslationMap = {
},
},
dreaming: {
agentSelect: {
label: "Agent",
ariaLabel: "Dreaming agent",
},
tabs: {
scene: "Scène",
diary: "Dagboek",
+4
View File
@@ -761,6 +761,10 @@ export const pl: TranslationMap = {
},
},
dreaming: {
agentSelect: {
label: "Agent",
ariaLabel: "Dreaming agent",
},
tabs: {
scene: "Scena",
diary: "Dziennik",
+4
View File
@@ -759,6 +759,10 @@ export const pt_BR: TranslationMap = {
},
},
dreaming: {
agentSelect: {
label: "Agente",
ariaLabel: "Dreaming agent",
},
tabs: {
scene: "Cena",
diary: "Diário",
+4
View File
@@ -751,6 +751,10 @@ export const th: TranslationMap = {
},
},
dreaming: {
agentSelect: {
label: "เอเจนต์",
ariaLabel: "Dreaming agent",
},
tabs: {
scene: "ฉาก",
diary: "ไดอารี",
+4
View File
@@ -764,6 +764,10 @@ export const tr: TranslationMap = {
},
},
dreaming: {
agentSelect: {
label: "Aracı",
ariaLabel: "Dreaming agent",
},
tabs: {
scene: "Sahne",
diary: "Günlük",
+4
View File
@@ -762,6 +762,10 @@ export const uk: TranslationMap = {
},
},
dreaming: {
agentSelect: {
label: "Агент",
ariaLabel: "Dreaming agent",
},
tabs: {
scene: "Сцена",
diary: "Щоденник",
+4
View File
@@ -757,6 +757,10 @@ export const vi: TranslationMap = {
},
},
dreaming: {
agentSelect: {
label: "Agent",
ariaLabel: "Dreaming agent",
},
tabs: {
scene: "Cảnh",
diary: "Nhật ký",
+4
View File
@@ -750,6 +750,10 @@ export const zh_CN: TranslationMap = {
},
},
dreaming: {
agentSelect: {
label: "代理",
ariaLabel: "Dreaming agent",
},
tabs: {
scene: "场景",
diary: "日记",
+4
View File
@@ -750,6 +750,10 @@ export const zh_TW: TranslationMap = {
},
},
dreaming: {
agentSelect: {
label: "代理",
ariaLabel: "Dreaming agent",
},
tabs: {
scene: "場景",
diary: "日誌",
+43 -6
View File
@@ -27,6 +27,11 @@ import {
import { hasOperatorWriteAccess, warnQueryToken } from "./app-settings.ts";
import type { AppViewState } from "./app-view-state.ts";
import { reconcileChatRunLifecycle } from "./chat/run-lifecycle.ts";
import {
resolveChatAgentFilterId,
resolveChatAgentFilterOptions,
resolvePreferredSessionForAgent,
} from "./chat/session-controls.ts";
import {
controlUiNowMs,
recordControlUiRenderTiming,
@@ -1028,10 +1033,16 @@ export function renderApp(state: AppViewState) {
const configuredDreaming = resolveConfiguredDreaming(configValue);
const dreamingOn = state.dreamingStatus?.enabled ?? configuredDreaming.enabled;
const dreamingNextCycle = resolveDreamingNextCycle(state.dreamingStatus);
const dreamingAgentOptions = resolveChatAgentFilterOptions(state);
const dreamingSelectedAgentId = resolveChatAgentFilterId(state, state.sessionKey);
const syncDreamingSelectedAgent = () => {
state.selectedAgentId = dreamingSelectedAgentId;
};
const dreamingLoading = state.dreamingStatusLoading || state.dreamingModeSaving;
const dreamingRefreshLoading = state.dreamingStatusLoading || state.dreamDiaryLoading;
const refreshDreaming = () => {
void (async () => {
syncDreamingSelectedAgent();
await loadConfig(state);
await Promise.all([
loadDreamingStatus(state),
@@ -3085,6 +3096,8 @@ export function renderApp(state: AppViewState) {
${state.tab === "dreams"
? renderDreaming({
active: dreamingOn,
selectedAgentId: dreamingSelectedAgentId,
agentOptions: dreamingAgentOptions,
shortTermCount: state.dreamingStatus?.shortTermCount ?? 0,
groundedSignalCount: state.dreamingStatus?.groundedSignalCount ?? 0,
totalSignalCount: state.dreamingStatus?.totalSignalCount ?? 0,
@@ -3117,7 +3130,16 @@ export function renderApp(state: AppViewState) {
wikiMemoryPalaceError: state.wikiMemoryPalaceError,
wikiMemoryPalace: state.wikiMemoryPalace,
onRefresh: refreshDreaming,
onRefreshDiary: () => loadDreamDiary(state),
onSelectAgent: (agentId: string) => {
state.selectedAgentId = agentId;
switchChatSession(state, resolvePreferredSessionForAgent(state, agentId));
void loadDreamingStatus(state);
void loadDreamDiary(state);
},
onRefreshDiary: () => {
syncDreamingSelectedAgent();
void loadDreamDiary(state);
},
onRefreshImports: () => {
void (async () => {
await loadConfig(state);
@@ -3132,14 +3154,29 @@ export function renderApp(state: AppViewState) {
},
onOpenConfig: () => openConfigFile(state),
onOpenWikiPage: (lookup: string) => openWikiPage(lookup),
onBackfillDiary: () => backfillDreamDiary(state),
onBackfillDiary: () => {
syncDreamingSelectedAgent();
void backfillDreamDiary(state);
},
onCopyDreamingArchivePath: () => {
void copyDreamingArchivePath(state);
},
onDedupeDreamDiary: () => dedupeDreamDiary(state),
onResetDiary: () => resetDreamDiary(state),
onResetGroundedShortTerm: () => resetGroundedShortTerm(state),
onRepairDreamingArtifacts: () => repairDreamingArtifacts(state),
onDedupeDreamDiary: () => {
syncDreamingSelectedAgent();
void dedupeDreamDiary(state);
},
onResetDiary: () => {
syncDreamingSelectedAgent();
void resetDreamDiary(state);
},
onResetGroundedShortTerm: () => {
syncDreamingSelectedAgent();
void resetGroundedShortTerm(state);
},
onRepairDreamingArtifacts: () => {
syncDreamingSelectedAgent();
void repairDreamingArtifacts(state);
},
onRequestUpdate: requestHostUpdate,
})
: nothing}
@@ -42,6 +42,10 @@ const mocks = vi.hoisted(() => ({
loadCronRunsMock: vi.fn<() => Promise<CronRunsLoadStatus>>(async () => "ok"),
loadDebugMock: vi.fn(async () => {}),
loadDevicesMock: vi.fn(async () => {}),
loadDreamDiaryMock: vi.fn(async () => {}),
loadDreamingStatusMock: vi.fn(async () => {}),
loadWikiImportInsightsMock: vi.fn(async () => {}),
loadWikiMemoryPalaceMock: vi.fn(async () => {}),
loadExecApprovalsMock: vi.fn(async () => {}),
loadLogsMock: vi.fn(async () => {}),
loadModelAuthStatusStateMock: vi.fn(async () => {}),
@@ -105,6 +109,12 @@ vi.mock("./controllers/debug.ts", () => ({
vi.mock("./controllers/devices.ts", () => ({
loadDevices: mocks.loadDevicesMock,
}));
vi.mock("./controllers/dreaming.ts", () => ({
loadDreamDiary: mocks.loadDreamDiaryMock,
loadDreamingStatus: mocks.loadDreamingStatusMock,
loadWikiImportInsights: mocks.loadWikiImportInsightsMock,
loadWikiMemoryPalace: mocks.loadWikiMemoryPalaceMock,
}));
vi.mock("./controllers/exec-approvals.ts", () => ({
loadExecApprovals: mocks.loadExecApprovalsMock,
}));
@@ -157,6 +167,7 @@ function createHost() {
cronRunsJobId: null as string | null,
sessionsChangedReloadTimer: null as number | ReturnType<typeof globalThis.setTimeout> | null,
sessionKey: "main",
selectedAgentId: null as string | null,
settings: {},
basePath: "",
};
@@ -223,6 +234,27 @@ describe("refreshActiveTab", () => {
tools: null,
} as const;
it("syncs selected agent before refreshing the Dreams tab", async () => {
const host = createHost();
host.tab = "dreams";
host.sessionKey = "agent:research:main";
mocks.loadDreamingStatusMock.mockImplementationOnce(async () => {
expect(host.selectedAgentId).toBe("research");
});
mocks.loadDreamDiaryMock.mockImplementationOnce(async () => {
expect(host.selectedAgentId).toBe("research");
});
await refreshActiveTab(host as unknown as Parameters<typeof refreshActiveTab>[0]);
expect(host.selectedAgentId).toBe("research");
expect(mocks.loadConfigMock).toHaveBeenCalledOnce();
expect(mocks.loadDreamingStatusMock).toHaveBeenCalledWith(host);
expect(mocks.loadDreamDiaryMock).toHaveBeenCalledWith(host);
expect(mocks.loadWikiImportInsightsMock).toHaveBeenCalledWith(host);
expect(mocks.loadWikiMemoryPalaceMock).toHaveBeenCalledWith(host);
});
for (const panel of ["files", "skills", "channels", "tools"] as const) {
it(`routes agents ${panel} panel refresh through the expected loaders`, async () => {
const host = createHost();
+9
View File
@@ -66,6 +66,7 @@ import {
tabFromPath,
type Tab,
} from "./navigation.ts";
import { normalizeAgentId, parseAgentSessionKey } from "./session-key.ts";
import {
normalizeTextScale,
saveLocalUserIdentity,
@@ -100,6 +101,7 @@ type SettingsHost = {
eventLogBuffer: unknown[];
basePath: string;
agentsList?: AgentsListResult | null;
selectedAgentId?: string | null;
agentsSelectedId?: string | null;
agentsPanel?: "overview" | "files" | "tools" | "skills" | "channels" | "cron";
pendingGatewayUrl?: string | null;
@@ -127,6 +129,12 @@ type LocalUserIdentityHost = {
userAvatar?: string | null;
};
function resolveDreamingAgentIdForSession(host: SettingsHost): string {
return normalizeAgentId(
parseAgentSessionKey(host.sessionKey)?.agentId ?? host.agentsList?.defaultId ?? "main",
);
}
type SettingsAppHost = SettingsHost &
AgentFilesState &
AgentIdentityState &
@@ -466,6 +474,7 @@ export async function refreshActiveTab(host: SettingsHost) {
await Promise.allSettled([loadDevices(app), loadConfig(app), loadExecApprovals(app)]);
break;
case "dreams":
host.selectedAgentId = resolveDreamingAgentIdForSession(host);
await loadConfig(app);
await Promise.all([
loadDreamingStatus(app),
+1
View File
@@ -204,6 +204,7 @@ export type AppViewState = {
configUiHints: ConfigUiHints;
configForm: Record<string, unknown> | null;
configFormOriginal: Record<string, unknown> | null;
selectedAgentId: string | null;
dreamingStatusLoading: boolean;
dreamingStatusError: string | null;
dreamingStatus: import("./controllers/dreaming.js").DreamingStatus | null;
+1
View File
@@ -344,6 +344,7 @@ export class OpenClawApp extends LitElement {
@state() configUiHints: ConfigUiHints = {};
@state() configForm: Record<string, unknown> | null = null;
@state() configFormOriginal: Record<string, unknown> | null = null;
@state() selectedAgentId: string | null = null;
@state() dreamingStatusLoading = false;
@state() dreamingStatusError: string | null = null;
@state() dreamingStatus: DreamingStatus | null = null;
+3 -3
View File
@@ -1120,7 +1120,7 @@ type ChatAgentFilterOption = {
label: string;
};
function resolveChatAgentFilterId(state: AppViewState, sessionKey: string): string {
export function resolveChatAgentFilterId(state: AppViewState, sessionKey: string): string {
const parsed = parseAgentSessionKey(sessionKey);
return normalizeAgentId(parsed?.agentId ?? state.agentsList?.defaultId ?? "main");
}
@@ -1186,7 +1186,7 @@ function rowsForPreferredAgentSession(
return [...byKey.values()];
}
function resolvePreferredSessionForAgent(state: AppViewState, agentId: string): string {
export function resolvePreferredSessionForAgent(state: AppViewState, agentId: string): string {
const normalizedAgentId = normalizeAgentId(agentId);
if (resolveChatAgentFilterId(state, state.sessionKey) === normalizedAgentId) {
return state.sessionKey;
@@ -1206,7 +1206,7 @@ function resolvePreferredSessionForAgent(state: AppViewState, agentId: string):
return buildAgentMainSessionKey({ agentId: normalizedAgentId });
}
function resolveChatAgentFilterOptions(state: AppViewState): ChatAgentFilterOption[] {
export function resolveChatAgentFilterOptions(state: AppViewState): ChatAgentFilterOption[] {
const seen = new Set<string>();
const options: ChatAgentFilterOption[] = [];
const add = (agentId: string) => {
+211
View File
@@ -25,6 +25,7 @@ function createState(): { state: DreamingState; request: ReturnType<typeof vi.fn
hello: null,
configSnapshot: { hash: "hash-1" },
applySessionKey: "main",
selectedAgentId: null,
dreamingStatusLoading: false,
dreamingStatusError: null,
dreamingStatus: null,
@@ -47,6 +48,19 @@ function createState(): { state: DreamingState; request: ReturnType<typeof vi.fn
return { state, request };
}
function createDeferred<T>() {
let resolve: ((value: T | PromiseLike<T>) => void) | undefined;
let reject: ((reason?: unknown) => void) | undefined;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
if (!resolve || !reject) {
throw new Error("Expected deferred promise callbacks to be initialized");
}
return { promise, resolve, reject };
}
function getConfigPatchRawPayload(request: ReturnType<typeof vi.fn>): Record<string, unknown> {
const patchCall = request.mock.calls.find((entry) => entry[0] === "config.patch");
if (!patchCall) {
@@ -211,6 +225,90 @@ describe("dreaming controller", () => {
expect(state.dreamingStatusError).toBeNull();
});
it("loads dreaming status for the selected agent", async () => {
const { state, request } = createState();
state.selectedAgentId = "research-analyst";
request.mockResolvedValue({
dreaming: {
enabled: true,
shortTermCount: 1,
},
});
await loadDreamingStatus(state);
expect(request).toHaveBeenCalledWith("doctor.memory.status", {
agentId: "research-analyst",
});
});
it("starts a new selected-agent status load and ignores stale completions", async () => {
const { state, request } = createState();
const agentA = createDeferred<unknown>();
const agentB = createDeferred<unknown>();
request.mockImplementation(async (_method: string, payload?: { agentId?: string }) => {
return payload?.agentId === "agent-b" ? agentB.promise : agentA.promise;
});
state.selectedAgentId = "agent-a";
const firstLoad = loadDreamingStatus(state);
state.selectedAgentId = "agent-b";
const secondLoad = loadDreamingStatus(state);
expect(request).toHaveBeenCalledWith("doctor.memory.status", { agentId: "agent-a" });
expect(request).toHaveBeenCalledWith("doctor.memory.status", { agentId: "agent-b" });
agentB.resolve({ dreaming: { enabled: true, shortTermCount: 2 } });
await secondLoad;
expect(state.dreamingStatus?.shortTermCount).toBe(2);
expect(state.dreamingStatusLoading).toBe(false);
agentA.resolve({ dreaming: { enabled: true, shortTermCount: 1 } });
await firstLoad;
expect(state.dreamingStatus?.shortTermCount).toBe(2);
expect(state.dreamingStatusLoading).toBe(false);
expect(state.dreamingStatusError).toBeNull();
});
it("ignores older same-agent status completions after switching back", async () => {
const { state, request } = createState();
const firstAgentA = createDeferred<unknown>();
const agentB = createDeferred<unknown>();
const secondAgentA = createDeferred<unknown>();
request
.mockImplementationOnce(async () => firstAgentA.promise)
.mockImplementationOnce(async () => agentB.promise)
.mockImplementationOnce(async () => secondAgentA.promise);
state.selectedAgentId = "agent-a";
const firstLoad = loadDreamingStatus(state);
state.selectedAgentId = "agent-b";
const secondLoad = loadDreamingStatus(state);
state.selectedAgentId = "agent-a";
const thirdLoad = loadDreamingStatus(state);
expect(request).toHaveBeenCalledWith("doctor.memory.status", { agentId: "agent-a" });
expect(request).toHaveBeenCalledWith("doctor.memory.status", { agentId: "agent-b" });
expect(request).toHaveBeenCalledTimes(3);
secondAgentA.resolve({ dreaming: { enabled: true, shortTermCount: 3 } });
await thirdLoad;
expect(state.dreamingStatus?.shortTermCount).toBe(3);
expect(state.dreamingStatusLoading).toBe(false);
firstAgentA.resolve({ dreaming: { enabled: true, shortTermCount: 1 } });
agentB.resolve({ dreaming: { enabled: true, shortTermCount: 2 } });
await firstLoad;
await secondLoad;
expect(state.dreamingStatus?.shortTermCount).toBe(3);
expect(state.dreamingStatusLoading).toBe(false);
expect(state.dreamingStatusError).toBeNull();
});
it("preserves unknown phase state when status omits phase metadata", async () => {
const { state, request } = createState();
request.mockResolvedValue({
@@ -849,6 +947,89 @@ describe("dreaming controller", () => {
expect(state.dreamDiaryError).toBeNull();
});
it("loads dream diary content for the selected agent", async () => {
const { state, request } = createState();
state.selectedAgentId = "infra-sre";
request.mockResolvedValue({
found: true,
path: "DREAMS.md",
content: "infra dreams",
});
await loadDreamDiary(state);
expect(request).toHaveBeenCalledWith("doctor.memory.dreamDiary", {
agentId: "infra-sre",
});
});
it("starts a new selected-agent diary load and ignores stale completions", async () => {
const { state, request } = createState();
const agentA = createDeferred<unknown>();
const agentB = createDeferred<unknown>();
request.mockImplementation(async (_method: string, payload?: { agentId?: string }) => {
return payload?.agentId === "agent-b" ? agentB.promise : agentA.promise;
});
state.selectedAgentId = "agent-a";
const firstLoad = loadDreamDiary(state);
state.selectedAgentId = "agent-b";
const secondLoad = loadDreamDiary(state);
expect(request).toHaveBeenCalledWith("doctor.memory.dreamDiary", { agentId: "agent-a" });
expect(request).toHaveBeenCalledWith("doctor.memory.dreamDiary", { agentId: "agent-b" });
agentB.resolve({ found: true, path: "DREAMS.md", content: "agent-b diary" });
await secondLoad;
expect(state.dreamDiaryContent).toBe("agent-b diary");
expect(state.dreamDiaryLoading).toBe(false);
agentA.resolve({ found: true, path: "DREAMS.md", content: "agent-a diary" });
await firstLoad;
expect(state.dreamDiaryContent).toBe("agent-b diary");
expect(state.dreamDiaryLoading).toBe(false);
expect(state.dreamDiaryError).toBeNull();
});
it("ignores older same-agent diary completions after switching back", async () => {
const { state, request } = createState();
const firstAgentA = createDeferred<unknown>();
const agentB = createDeferred<unknown>();
const secondAgentA = createDeferred<unknown>();
request
.mockImplementationOnce(async () => firstAgentA.promise)
.mockImplementationOnce(async () => agentB.promise)
.mockImplementationOnce(async () => secondAgentA.promise);
state.selectedAgentId = "agent-a";
const firstLoad = loadDreamDiary(state);
state.selectedAgentId = "agent-b";
const secondLoad = loadDreamDiary(state);
state.selectedAgentId = "agent-a";
const thirdLoad = loadDreamDiary(state);
expect(request).toHaveBeenCalledWith("doctor.memory.dreamDiary", { agentId: "agent-a" });
expect(request).toHaveBeenCalledWith("doctor.memory.dreamDiary", { agentId: "agent-b" });
expect(request).toHaveBeenCalledTimes(3);
secondAgentA.resolve({ found: true, path: "DREAMS.md", content: "new agent-a diary" });
await thirdLoad;
expect(state.dreamDiaryContent).toBe("new agent-a diary");
expect(state.dreamDiaryLoading).toBe(false);
firstAgentA.resolve({ found: true, path: "DREAMS.md", content: "old agent-a diary" });
agentB.resolve({ found: true, path: "DREAMS.md", content: "agent-b diary" });
await firstLoad;
await secondLoad;
expect(state.dreamDiaryContent).toBe("new agent-a diary");
expect(state.dreamDiaryLoading).toBe(false);
expect(state.dreamDiaryError).toBeNull();
});
it("handles missing dream diary without error", async () => {
const { state, request } = createState();
request.mockResolvedValue({
@@ -941,6 +1122,36 @@ describe("dreaming controller", () => {
expect(state.dreamDiaryActionLoading).toBe(false);
});
it("runs dream diary actions and reloads state for the selected agent", async () => {
const { state, request } = createState();
state.selectedAgentId = "fishing-bot";
request.mockImplementation(async (method: string) => {
if (method === "doctor.memory.backfillDreamDiary") {
return { action: "backfill", written: 1 };
}
if (method === "doctor.memory.dreamDiary") {
return { found: true, path: "DREAMS.md", content: "fish dreams" };
}
if (method === "doctor.memory.status") {
return { dreaming: null };
}
return {};
});
const ok = await backfillDreamDiary(state);
expect(ok).toBe(true);
expect(request).toHaveBeenCalledWith("doctor.memory.backfillDreamDiary", {
agentId: "fishing-bot",
});
expect(request).toHaveBeenCalledWith("doctor.memory.dreamDiary", {
agentId: "fishing-bot",
});
expect(request).toHaveBeenCalledWith("doctor.memory.status", {
agentId: "fishing-bot",
});
});
it("resets and reloads dream diary state", async () => {
const { state, request } = createState();
request.mockImplementation(async (method: string) => {
+96 -9
View File
@@ -211,10 +211,19 @@ export type DreamingState = {
hello: GatewayHelloOk | null;
configSnapshot: ConfigSnapshot | null;
applySessionKey: string;
selectedAgentId: string | null;
dreamingStatusRequestAgentId?: string | null;
dreamingStatusRequestGeneration?: number;
dreamingStatusActiveRequestGeneration?: number | null;
dreamingStatusAgentId?: string | null;
dreamingStatusLoading: boolean;
dreamingStatusError: string | null;
dreamingStatus: DreamingStatus | null;
dreamingModeSaving: boolean;
dreamDiaryRequestAgentId?: string | null;
dreamDiaryRequestGeneration?: number;
dreamDiaryActiveRequestGeneration?: number | null;
dreamDiaryAgentId?: string | null;
dreamDiaryLoading: boolean;
dreamDiaryActionLoading: boolean;
dreamDiaryActionMessage: { kind: "success" | "error"; text: string } | null;
@@ -326,6 +335,22 @@ function normalizeTrimmedString(value: unknown): string | undefined {
return trimmed.length > 0 ? trimmed : undefined;
}
function resolveSelectedAgentId(state: DreamingState): string | null {
return normalizeTrimmedString(state.selectedAgentId) ?? null;
}
function buildSelectedAgentPayloadForAgentId(
agentId: string | null,
): { agentId: string } | Record<string, never> {
return agentId ? { agentId } : {};
}
function buildSelectedAgentPayload(
state: DreamingState,
): { agentId: string } | Record<string, never> {
return buildSelectedAgentPayloadForAgentId(resolveSelectedAgentId(state));
}
function normalizeBoolean(value: unknown, fallback = false): boolean {
return typeof value === "boolean" ? value : fallback;
}
@@ -775,35 +800,83 @@ function normalizeDreamingStatus(raw: unknown): DreamingStatus | null {
}
export async function loadDreamingStatus(state: DreamingState): Promise<void> {
if (!state.client || !state.connected || state.dreamingStatusLoading) {
if (!state.client || !state.connected) {
return;
}
const agentId = resolveSelectedAgentId(state);
if (state.dreamingStatusLoading && state.dreamingStatusRequestAgentId === agentId) {
return;
}
if (state.dreamingStatusAgentId !== agentId) {
state.dreamingStatus = null;
}
const requestGeneration = (state.dreamingStatusRequestGeneration ?? 0) + 1;
state.dreamingStatusRequestGeneration = requestGeneration;
state.dreamingStatusActiveRequestGeneration = requestGeneration;
state.dreamingStatusRequestAgentId = agentId;
state.dreamingStatusLoading = true;
state.dreamingStatusError = null;
try {
const payload = await state.client.request<DoctorMemoryStatusPayload>(
"doctor.memory.status",
{},
buildSelectedAgentPayloadForAgentId(agentId),
);
if (
state.dreamingStatusActiveRequestGeneration !== requestGeneration ||
state.dreamingStatusRequestAgentId !== agentId ||
resolveSelectedAgentId(state) !== agentId
) {
return;
}
state.dreamingStatus = normalizeDreamingStatus(payload?.dreaming);
state.dreamingStatusAgentId = agentId;
} catch (err) {
state.dreamingStatusError = String(err);
if (
state.dreamingStatusActiveRequestGeneration === requestGeneration &&
state.dreamingStatusRequestAgentId === agentId &&
resolveSelectedAgentId(state) === agentId
) {
state.dreamingStatusError = String(err);
}
} finally {
state.dreamingStatusLoading = false;
if (state.dreamingStatusActiveRequestGeneration === requestGeneration) {
state.dreamingStatusLoading = false;
state.dreamingStatusRequestAgentId = null;
state.dreamingStatusActiveRequestGeneration = null;
}
}
}
export async function loadDreamDiary(state: DreamingState): Promise<void> {
if (!state.client || !state.connected || state.dreamDiaryLoading) {
if (!state.client || !state.connected) {
return;
}
const agentId = resolveSelectedAgentId(state);
if (state.dreamDiaryLoading && state.dreamDiaryRequestAgentId === agentId) {
return;
}
if (state.dreamDiaryAgentId !== agentId) {
state.dreamDiaryPath = null;
state.dreamDiaryContent = null;
}
const requestGeneration = (state.dreamDiaryRequestGeneration ?? 0) + 1;
state.dreamDiaryRequestGeneration = requestGeneration;
state.dreamDiaryActiveRequestGeneration = requestGeneration;
state.dreamDiaryRequestAgentId = agentId;
state.dreamDiaryLoading = true;
state.dreamDiaryError = null;
try {
const payload = await state.client.request<DoctorMemoryDreamDiaryPayload>(
"doctor.memory.dreamDiary",
{},
buildSelectedAgentPayloadForAgentId(agentId),
);
if (
state.dreamDiaryActiveRequestGeneration !== requestGeneration ||
state.dreamDiaryRequestAgentId !== agentId ||
resolveSelectedAgentId(state) !== agentId
) {
return;
}
const path = normalizeTrimmedString(payload?.path) ?? DEFAULT_DREAM_DIARY_PATH;
const found = payload?.found === true;
if (found) {
@@ -813,10 +886,21 @@ export async function loadDreamDiary(state: DreamingState): Promise<void> {
state.dreamDiaryPath = path;
state.dreamDiaryContent = null;
}
state.dreamDiaryAgentId = agentId;
} catch (err) {
state.dreamDiaryError = String(err);
if (
state.dreamDiaryActiveRequestGeneration === requestGeneration &&
state.dreamDiaryRequestAgentId === agentId &&
resolveSelectedAgentId(state) === agentId
) {
state.dreamDiaryError = String(err);
}
} finally {
state.dreamDiaryLoading = false;
if (state.dreamDiaryActiveRequestGeneration === requestGeneration) {
state.dreamDiaryLoading = false;
state.dreamDiaryRequestAgentId = null;
state.dreamDiaryActiveRequestGeneration = null;
}
}
}
@@ -902,7 +986,10 @@ async function runDreamDiaryAction(
state.dreamDiaryActionMessage = null;
state.dreamDiaryActionArchivePath = null;
try {
const payload = await state.client.request<DoctorMemoryDreamActionPayload>(method, {});
const payload = await state.client.request<DoctorMemoryDreamActionPayload>(
method,
buildSelectedAgentPayload(state),
);
if (options?.reloadDiary !== false) {
await loadDreamDiary(state);
}
+6 -2
View File
@@ -264,7 +264,9 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
await main.getByRole("button", { name: "Chat session" }).click();
await page
.locator('button[data-chat-session-picker-option="true"][data-session-key="agent:main:session-b"]')
.locator(
'button[data-chat-session-picker-option="true"][data-session-key="agent:main:session-b"]',
)
.click();
await main.getByRole("button", { name: "Chat session" }).getByText("Session B").waitFor({
timeout: 10_000,
@@ -273,7 +275,9 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
await main.getByRole("button", { name: "Chat session" }).click();
await page
.locator('button[data-chat-session-picker-option="true"][data-session-key="agent:main:session-a"]')
.locator(
'button[data-chat-session-picker-option="true"][data-session-key="agent:main:session-a"]',
)
.click();
await main.getByRole("button", { name: "Chat session" }).getByText("Session A").waitFor({
timeout: 10_000,
+8 -2
View File
@@ -11,8 +11,13 @@ import {
} from "./dreaming.ts";
function buildProps(overrides?: Partial<DreamingProps>): DreamingProps {
return {
const props: DreamingProps = {
active: true,
selectedAgentId: "main",
agentOptions: [
{ id: "main", label: "main" },
{ id: "ceo", label: "ceo" },
],
shortTermCount: 47,
groundedSignalCount: 9,
totalSignalCount: 182,
@@ -184,6 +189,7 @@ function buildProps(overrides?: Partial<DreamingProps>): DreamingProps {
],
},
onRefresh: () => {},
onSelectAgent: () => {},
onRefreshDiary: () => {},
onRefreshImports: () => {},
onRefreshMemoryPalace: () => {},
@@ -195,8 +201,8 @@ function buildProps(overrides?: Partial<DreamingProps>): DreamingProps {
onResetDiary: () => {},
onResetGroundedShortTerm: () => {},
onRepairDreamingArtifacts: () => {},
...overrides,
};
return { ...props, ...overrides };
}
function renderInto(props: DreamingProps): HTMLDivElement {
+73 -39
View File
@@ -1,11 +1,11 @@
import { html, nothing } from "lit";
import { repeat } from "lit/directives/repeat.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { t } from "../../i18n/index.ts";
import type {
DreamingEntry,
WikiImportInsights,
WikiMemoryPalace,
WikiMemoryPalaceItem,
} from "../controllers/dreaming.ts";
import { toSanitizedMarkdownHtml } from "../markdown.ts";
@@ -92,8 +92,15 @@ type DreamingPhaseInfo = {
nextRunAtMs?: number;
};
type DreamingAgentOption = {
id: string;
label: string;
};
export type DreamingProps = {
active: boolean;
selectedAgentId: string;
agentOptions: DreamingAgentOption[];
shortTermCount: number;
groundedSignalCount: number;
totalSignalCount: number;
@@ -126,6 +133,7 @@ export type DreamingProps = {
wikiMemoryPalaceError: string | null;
wikiMemoryPalace: WikiMemoryPalace | null;
onRefresh: () => void;
onSelectAgent: (agentId: string) => void;
onRefreshDiary: () => void;
onRefreshImports: () => void;
onRefreshMemoryPalace: () => void;
@@ -292,35 +300,63 @@ export function renderDreaming(props: DreamingProps) {
return html`
<div class="dreams-page">
<!-- ── Sub-tab bar ── -->
<nav class="dreams__tabs">
<button
class="dreams__tab ${activeSubTab === "scene" ? "dreams__tab--active" : ""}"
@click=${() => {
activeSubTab = "scene";
props.onRequestUpdate?.();
}}
>
${t("dreaming.tabs.scene")}
</button>
<button
class="dreams__tab ${activeSubTab === "diary" ? "dreams__tab--active" : ""}"
@click=${() => {
activeSubTab = "diary";
props.onRequestUpdate?.();
}}
>
${t("dreaming.tabs.diary")}
</button>
<button
class="dreams__tab ${activeSubTab === "advanced" ? "dreams__tab--active" : ""}"
@click=${() => {
activeSubTab = "advanced";
props.onRequestUpdate?.();
}}
>
${t("dreaming.tabs.advanced")}
</button>
</nav>
<div class="dreams__topbar">
<nav class="dreams__tabs">
<button
class="dreams__tab ${activeSubTab === "scene" ? "dreams__tab--active" : ""}"
@click=${() => {
activeSubTab = "scene";
props.onRequestUpdate?.();
}}
>
${t("dreaming.tabs.scene")}
</button>
<button
class="dreams__tab ${activeSubTab === "diary" ? "dreams__tab--active" : ""}"
@click=${() => {
activeSubTab = "diary";
props.onRequestUpdate?.();
}}
>
${t("dreaming.tabs.diary")}
</button>
<button
class="dreams__tab ${activeSubTab === "advanced" ? "dreams__tab--active" : ""}"
@click=${() => {
activeSubTab = "advanced";
props.onRequestUpdate?.();
}}
>
${t("dreaming.tabs.advanced")}
</button>
</nav>
${props.agentOptions.length > 1
? html`<label class="field dreams__agent-select">
<span class="sr-only">${t("dreaming.agentSelect.label")}</span>
<select
data-dreaming-agent-select="true"
aria-label=${t("dreaming.agentSelect.ariaLabel")}
.value=${props.selectedAgentId}
@change=${(e: Event) => {
const nextAgentId = (e.target as HTMLSelectElement).value;
if (nextAgentId === props.selectedAgentId) {
return;
}
props.onSelectAgent(nextAgentId);
}}
>
${repeat(
props.agentOptions,
(entry) => entry.id,
(entry) =>
html`<option value=${entry.id} ?selected=${entry.id === props.selectedAgentId}>
${entry.label}
</option>`,
)}
</select>
</label>`
: nothing}
</div>
${activeSubTab === "scene"
? renderScene(props, idle, dreamText)
@@ -579,14 +615,6 @@ function toggleExpandedCard(bucket: Set<string>, key: string, requestUpdate?: ()
requestUpdate?.();
}
function handleMemoryPalaceCardClick(item: WikiMemoryPalaceItem, props: DreamingProps): void {
if (item.kind === "report") {
void openWikiPreview(item.pagePath, props);
return;
}
toggleExpandedCard(expandedPalaceCards, item.pagePath, props.onRequestUpdate);
}
async function openWikiPreview(lookup: string, props: DreamingProps): Promise<void> {
wikiPreviewOpen = true;
wikiPreviewLoading = true;
@@ -1253,7 +1281,13 @@ function renderMemoryPalaceSection(props: DreamingProps) {
<article
class="dreams-diary__insight-card dreams-diary__insight-card--clickable"
data-palace-page=${item.pagePath}
@click=${() => handleMemoryPalaceCardClick(item, props)}
@click=${() => {
if (item.kind === "report") {
void openWikiPreview(item.pagePath, props);
return;
}
toggleExpandedCard(expandedPalaceCards, item.pagePath, props.onRequestUpdate);
}}
>
<div class="dreams-diary__insight-topline">
<div class="dreams-diary__insight-title">${item.title}</div>