mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-20 17:21:33 +00:00
* feat: add session thread management Squash of codex/thread-management (025aefc3ad1) onto origin/main: pin/archive/rename sessions via sessions.patch, archived-aware sessions.list, lifecycle fencing, read-only archived chat, SDK + Swift protocol support, Control UI session management. * refactor(ui): minimal session rows with hover-revealed management Chat picker and sidebar recents share session-row primitives: single-line rows, relative timestamps, rename/archive/pin revealed on hover or focus, accent pin badge for pinned rows, and an active-run spinner in the trail slot. Sidebar floats pinned sessions above recency via the shared comparator and gains archive/pin actions through the unified sessions-view patch fallback. Archive eligibility is one shared policy (canArchiveSessionRow); the sidebar/picker active-run tooltip now uses the real sessionsView.activeRun locale key. * fix: align session admission with mailbox-era main Integration fixes after rebasing onto current main: sessions_list mailbox test expectations learn the archived/pinned row fields and archived:false list param; gateway agent admission treats a session as deleted only when both the requested and canonical alias sets miss it (legacy bare-main stores and exec-approval followups read under different spellings); cron persist tests keep a consistent store across claim-guarded persist calls; the ACP abort hook test asserts abort propagation instead of signal identity; drop dead lifecycle writes flagged by no-useless-assignment and fix the promise-executor return in the codex compact test. * fix(qa): align UI e2e and shard fixtures with redesigned session rows Sidebar session rows are wrapper divs with an inner link now: update the navigation browser tests and chat-flow Playwright selectors. Seed a real per-test session store for the auto-fallback admission guard instead of depending on leftover host files at /tmp/sessions.json. Teach the test-projects routing fixture about the suites that newly import the shared temp-dir helper. Document the Codex thread-format contract for archivedAt/pinnedAt (flag derived from server-stamped timestamp, epoch ms here vs Codex epoch seconds) at the type and in the session docs. * test: route auto-fallback suite through temp-dir helper plans The auto-fallback suite now imports the shared temp-dir helper for its seeded session store, so the top-level helper routing fixture must list it in the auto-reply plan.
124 lines
3.9 KiB
TypeScript
124 lines
3.9 KiB
TypeScript
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import {
|
|
interruptSessionWorkAdmissions,
|
|
runExclusiveSessionLifecycleMutation,
|
|
} from "../../sessions/session-lifecycle-admission.js";
|
|
import { createRuntimeAgent } from "./runtime-agent.js";
|
|
|
|
function createDeferred(): { promise: Promise<void>; resolve: () => void } {
|
|
let resolve = () => {};
|
|
const promise = new Promise<void>((resolvePromise) => {
|
|
resolve = resolvePromise;
|
|
});
|
|
return { promise, resolve };
|
|
}
|
|
|
|
describe("plugin runtime session work admission", () => {
|
|
let tempDir: string;
|
|
let storePath: string;
|
|
const sessionKey = "agent:main:voice:caller";
|
|
const sessionId = "voice-session-id";
|
|
|
|
beforeEach(async () => {
|
|
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-session-admission-"));
|
|
storePath = path.join(tempDir, "sessions.json");
|
|
await createRuntimeAgent().session.upsertSessionEntry({
|
|
storePath,
|
|
sessionKey,
|
|
entry: { sessionId, updatedAt: Date.now() },
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("rejects an archived session before running admitted work", async () => {
|
|
const runtime = createRuntimeAgent();
|
|
await runtime.session.patchSessionEntry({
|
|
storePath,
|
|
sessionKey,
|
|
update: () => ({ archivedAt: Date.now() }),
|
|
});
|
|
let ran = false;
|
|
|
|
await expect(
|
|
runtime.session.runWithWorkAdmission({ storePath, sessionKey }, async () => {
|
|
ran = true;
|
|
}),
|
|
).rejects.toThrow(`Session "${sessionKey}" is archived`);
|
|
expect(ran).toBe(false);
|
|
});
|
|
|
|
it("waits for a queued archive mutation and rejects the stale start", async () => {
|
|
const runtime = createRuntimeAgent();
|
|
const mutationStarted = createDeferred();
|
|
const releaseMutation = createDeferred();
|
|
const mutation = runExclusiveSessionLifecycleMutation({
|
|
scope: storePath,
|
|
identities: [sessionKey, sessionId],
|
|
prepare: async () => {
|
|
mutationStarted.resolve();
|
|
await releaseMutation.promise;
|
|
},
|
|
run: async () => {
|
|
await runtime.session.patchSessionEntry({
|
|
storePath,
|
|
sessionKey,
|
|
update: () => ({ archivedAt: Date.now() }),
|
|
});
|
|
},
|
|
});
|
|
await mutationStarted.promise;
|
|
|
|
const work = runtime.session.runWithWorkAdmission({ storePath, sessionKey }, async () => {});
|
|
releaseMutation.resolve();
|
|
await mutation;
|
|
|
|
await expect(work).rejects.toThrow(`Session "${sessionKey}" is archived`);
|
|
});
|
|
|
|
it("admits fresh work and protects session creation inside the callback", async () => {
|
|
const runtime = createRuntimeAgent();
|
|
const freshKey = "agent:main:voice:fresh";
|
|
const freshId = "fresh-session-id";
|
|
|
|
await runtime.session.runWithWorkAdmission({ storePath, sessionKey: freshKey }, async () => {
|
|
await runtime.session.upsertSessionEntry({
|
|
storePath,
|
|
sessionKey: freshKey,
|
|
entry: { sessionId: freshId, updatedAt: Date.now() },
|
|
});
|
|
});
|
|
|
|
expect(runtime.session.getSessionEntry({ storePath, sessionKey: freshKey })?.sessionId).toBe(
|
|
freshId,
|
|
);
|
|
});
|
|
|
|
it("holds admission through the callback and relays lifecycle interruption", async () => {
|
|
const runtime = createRuntimeAgent();
|
|
const workStarted = createDeferred();
|
|
let admittedSignal: AbortSignal | undefined;
|
|
const work = runtime.session.runWithWorkAdmission({ storePath, sessionKey }, async (signal) => {
|
|
admittedSignal = signal;
|
|
workStarted.resolve();
|
|
await new Promise<void>((resolve) => {
|
|
signal.addEventListener("abort", () => resolve(), { once: true });
|
|
});
|
|
});
|
|
await workStarted.promise;
|
|
|
|
await interruptSessionWorkAdmissions({
|
|
scope: storePath,
|
|
identities: [sessionKey, sessionId],
|
|
});
|
|
await work;
|
|
|
|
expect(admittedSignal?.aborted).toBe(true);
|
|
});
|
|
});
|