feat: session source in session_meta and an Automated sidebar folder (#40)
Co-authored-by: Alice <alice@prismshadow.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
30 files changed
+1023
-74
No files matched your search
@@ -40,6 +40,7 @@ describe("scheduler", () => {
|
||||
workspace?: string;
|
||||
provider?: string;
|
||||
modelId?: string;
|
||||
source?: "schedule";
|
||||
}>;
|
||||
let events: Array<{ userId: string; event: ScheduleServerEvent }>;
|
||||
let errors: ErrorRecordArgs[];
|
||||
@@ -288,13 +289,15 @@ describe("scheduler", () => {
|
||||
nowMs = T0 + 6 * MIN;
|
||||
await scheduler.tickOnce();
|
||||
expect(created).toHaveLength(2);
|
||||
// The file's model reference is passed straight through as a pair.
|
||||
// The file's model reference is passed straight through as a pair; every schedule-opened
|
||||
// session is marked with its origin (SessionService stores it on the row and in core session_meta).
|
||||
expect(created[0]).toMatchObject({
|
||||
projectId: P,
|
||||
agentId: A,
|
||||
workspace: "/tmp/ws",
|
||||
provider: "custom",
|
||||
modelId: "m-bench",
|
||||
source: "schedule",
|
||||
});
|
||||
expect(started.map((s) => s.sessionId)).toEqual(["session-new-1", "session-new-2"]);
|
||||
});
|
||||
|
||||
@@ -103,6 +103,140 @@ describe("session-index", () => {
|
||||
expect(list.sessions.map((s) => s.sessionId)).toContain(session.sessionId);
|
||||
});
|
||||
|
||||
it("schedule-created Session: source derives from session_meta (registry), never from the DB row; user sessions carry none", async () => {
|
||||
await configureModels();
|
||||
// The scheduler goes through SessionService.createSession directly (no HTTP route exposes source).
|
||||
const info = await t.deps.sessionService.createSession({
|
||||
projectId,
|
||||
agentId: "default_agent",
|
||||
source: "schedule",
|
||||
});
|
||||
expect(info.source).toBe("schedule");
|
||||
// The index row stores no origin: session_meta is the single source of truth.
|
||||
const row = t.deps.sessionsRepo.findById(info.sessionId);
|
||||
expect(row && "source" in row).toBe(false);
|
||||
expect(t.deps.sessionSources.get(info.sessionId)).toBe("schedule");
|
||||
|
||||
// A user-created session (HTTP) has no source, and the list surfaces both accordingly.
|
||||
const res = await api.post(base(), {});
|
||||
expect(res.status).toBe(201);
|
||||
const { session: plain } = (await res.json()) as SessionCreateResponse;
|
||||
const list = (await (await api.get(base())).json()) as SessionsResponse;
|
||||
expect(list.sessions.find((s) => s.sessionId === info.sessionId)?.source).toBe("schedule");
|
||||
expect(list.sessions.find((s) => s.sessionId === plain.sessionId)?.source).toBeUndefined();
|
||||
});
|
||||
|
||||
it("source survives a restart via the Trace head: an indexed row unknown to this process derives it lazily from session_meta", async () => {
|
||||
await configureModels();
|
||||
// Simulate a Session created by a previous process: the index row exists, but the
|
||||
// in-process registry has never seen it — only its Trace's session_meta knows the origin.
|
||||
const sid = "session-2026-07-02-09-00-00-feedc0de";
|
||||
t.deps.sessionsRepo.insert({
|
||||
sessionId: sid,
|
||||
projectId,
|
||||
agentId: "default_agent",
|
||||
provider: "custom",
|
||||
modelId: "m-x",
|
||||
workspace: "/tmp/w-restart",
|
||||
approvalMode: "allow-all",
|
||||
title: null,
|
||||
createdAt: "2026-07-02T09:00:00.000Z",
|
||||
});
|
||||
const meta: SessionMetaPayload = {
|
||||
session_id: sid,
|
||||
model_id: "m-x",
|
||||
provider: "custom",
|
||||
model_context_window: 1000,
|
||||
system_prompt: "",
|
||||
tools: [],
|
||||
thinking_level: "default",
|
||||
agent_state: "/tmp/a",
|
||||
workspace: "/tmp/w-restart",
|
||||
source: "subagent",
|
||||
};
|
||||
await writeTraceFile(t.root, projectId, "default_agent", "2026-07-02", sid, 1, [
|
||||
sessionMeta(meta),
|
||||
userText("child work"),
|
||||
]);
|
||||
const list = (await (await api.get(base())).json()) as SessionsResponse;
|
||||
expect(list.sessions.find((s) => s.sessionId === sid)?.source).toBe("subagent");
|
||||
// The single-session endpoint derives it the same way (and the second read hits the registry).
|
||||
const single = (await (await api.get(`/api/sessions/${sid}`)).json()) as SessionResponse;
|
||||
expect(single.session.source).toBe("subagent");
|
||||
});
|
||||
|
||||
it("adoption derives source from the Trace meta, narrowing junk values to user-created", async () => {
|
||||
await configureModels();
|
||||
// Discovered (no index row) with a valid origin: adoption records it.
|
||||
const adopted = "session-2026-07-03-10-00-00-0badf00d";
|
||||
const sourced: SessionMetaPayload = {
|
||||
session_id: adopted,
|
||||
model_id: "m-cli",
|
||||
provider: "custom",
|
||||
model_context_window: 1000,
|
||||
system_prompt: "",
|
||||
tools: [],
|
||||
thinking_level: "default",
|
||||
agent_state: "/tmp/a",
|
||||
workspace: "/tmp/w-cli",
|
||||
source: "schedule",
|
||||
};
|
||||
await writeTraceFile(t.root, projectId, "default_agent", "2026-07-03", adopted, 1, [
|
||||
sessionMeta(sourced),
|
||||
userText("adopted"),
|
||||
]);
|
||||
// Discovered with a junk source (untrusted on-disk data): narrowed to user-created.
|
||||
const junk = "session-2026-07-03-11-00-00-0badf00e";
|
||||
const junkMeta = {
|
||||
...sourced,
|
||||
session_id: junk,
|
||||
source: "weird-origin",
|
||||
} as unknown as SessionMetaPayload;
|
||||
await writeTraceFile(t.root, projectId, "default_agent", "2026-07-03", junk, 1, [
|
||||
sessionMeta(junkMeta),
|
||||
userText("junk"),
|
||||
]);
|
||||
const list = (await (await api.get(base())).json()) as SessionsResponse;
|
||||
expect(list.sessions.find((s) => s.sessionId === adopted)?.source).toBe("schedule");
|
||||
expect(list.sessions.find((s) => s.sessionId === junk)?.source).toBeUndefined();
|
||||
});
|
||||
|
||||
it("list paging: limit/offset slice the newest-first list; absent params keep the full list; invalid values 400", async () => {
|
||||
await configureModels();
|
||||
// Three sessions with distinct createdAt ordering (insert directly for deterministic times).
|
||||
const mk = (n: number) => ({
|
||||
sessionId: `session-2026-07-0${n}-08-00-00-aaaa000${n}`,
|
||||
projectId,
|
||||
agentId: "default_agent",
|
||||
provider: "custom",
|
||||
modelId: "m-page",
|
||||
workspace: `/tmp/w-${n}`,
|
||||
approvalMode: "allow-all" as const,
|
||||
title: null,
|
||||
createdAt: `2026-07-0${n}T08:00:00.000Z`,
|
||||
});
|
||||
for (const n of [1, 2, 3]) t.deps.sessionsRepo.insert(mk(n));
|
||||
|
||||
const ids = async (qs: string) => {
|
||||
const res = await api.get(`${base()}${qs}`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as SessionsResponse;
|
||||
return body.sessions.map((s) => s.sessionId);
|
||||
};
|
||||
const all = await ids("");
|
||||
expect(all).toEqual([mk(3).sessionId, mk(2).sessionId, mk(1).sessionId]); // newest first, unpaged
|
||||
expect(await ids("?limit=2")).toEqual(all.slice(0, 2));
|
||||
expect(await ids("?limit=2&offset=2")).toEqual(all.slice(2));
|
||||
expect(await ids("?limit=2&offset=9")).toEqual([]); // past the end: empty page, not an error
|
||||
// The sidebar's limit+1 trick: one extra row answers "has more" without an envelope change.
|
||||
expect((await ids("?limit=3")).length).toBe(3);
|
||||
|
||||
for (const bad of ["?limit=0", "?limit=-1", "?limit=abc", "?limit=1001", "?offset=1"]) {
|
||||
expect((await api.get(`${base()}${bad}`)).status, bad).toBe(400);
|
||||
}
|
||||
expect((await api.get(`${base()}?limit=2&offset=-1`)).status).toBe(400);
|
||||
});
|
||||
|
||||
it("half a model reference is 400: the missing half is never inferred", async () => {
|
||||
await configureModels();
|
||||
// Only modelId: even though it names the one configured model, the provider is never
|
||||
|
||||
@@ -10,6 +10,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createAgent, saveProjectConfig, sessionMeta, userText } from "@prismshadow/penguin-core";
|
||||
import type { SessionMetaPayload } from "@prismshadow/penguin-core";
|
||||
import { createCoreSessionLoader } from "../src/runtime/session-manager.js";
|
||||
import { SessionSources } from "../src/runtime/session-sources.js";
|
||||
import type { SessionRow } from "../src/db/repos/sessions.js";
|
||||
import { HttpError } from "../src/http/errors.js";
|
||||
import { makeTempRoot, writeTraceFile } from "./helpers.js";
|
||||
@@ -99,4 +100,38 @@ describe("session-loader", () => {
|
||||
expect((err as HttpError).status).toBe(409);
|
||||
expect((err as HttpError).code).toBe("workspace_missing");
|
||||
});
|
||||
|
||||
it("self-heal rebuild re-records a registry-known origin in the fresh session_meta; unknown stays absent", async () => {
|
||||
// The anthropic pair constructs without a credential (the same pair session-index
|
||||
// creates over HTTP); custom/m1 would demand a key at client construction.
|
||||
await saveProjectConfig(root, PROJECT, {
|
||||
default_model: { provider: "anthropic", model_id: "claude-sonnet-4-6" },
|
||||
models: [{ provider: "anthropic", model_id: "claude-sonnet-4-6", context_window: 1000 }],
|
||||
});
|
||||
const ws = path.join(root, "ws-heal");
|
||||
await fs.mkdir(ws, { recursive: true });
|
||||
const healRow: SessionRow = {
|
||||
...row(ws),
|
||||
modelId: "claude-sonnet-4-6",
|
||||
provider: "anthropic",
|
||||
};
|
||||
|
||||
// Origin known to this process (e.g. a schedule-created Session rebuilt after adoption):
|
||||
// the fresh session_meta must re-record it — meta is the single source of truth, and the
|
||||
// rebuilt Session's Trace is the only place the origin can survive.
|
||||
const sources = new SessionSources();
|
||||
sources.set(SID, "schedule");
|
||||
const known = await createCoreSessionLoader(root, sources).load(healRow);
|
||||
const knownMeta = (known as unknown as { metaMessage: { payload: { source?: string } } })
|
||||
.metaMessage;
|
||||
expect(knownMeta.payload.source).toBe("schedule");
|
||||
(known as unknown as { dispose(): void }).dispose();
|
||||
|
||||
// No registry entry (e.g. the process restarted and no Trace was ever written): the
|
||||
// rebuilt Session is unsourced — no source key is invented.
|
||||
const unknown = await createCoreSessionLoader(root, new SessionSources()).load(healRow);
|
||||
const unknownMeta = (unknown as unknown as { metaMessage: { payload: object } }).metaMessage;
|
||||
expect("source" in unknownMeta.payload).toBe(false);
|
||||
(unknown as unknown as { dispose(): void }).dispose();
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,7 @@ import type { ChannelEvent } from "../src/runtime/channel.js";
|
||||
import type { ErrorRecordArgs, ErrorSink } from "../src/runtime/error-recorder.js";
|
||||
import { SessionManager } from "../src/runtime/session-manager.js";
|
||||
import type { RuntimeSession, SessionLoader } from "../src/runtime/session-manager.js";
|
||||
import { SessionSources } from "../src/runtime/session-sources.js";
|
||||
import type { TitleRequest } from "../src/runtime/title-generator.js";
|
||||
import type { UsageContext } from "../src/runtime/usage-recorder.js";
|
||||
import { waitFor } from "./helpers.js";
|
||||
@@ -78,6 +79,7 @@ describe("session-manager", () => {
|
||||
let db: DatabaseSync;
|
||||
let sessions: SessionsRepo;
|
||||
let channels: ChannelHub;
|
||||
let sources: SessionSources;
|
||||
let recorded: OmniMessage[];
|
||||
let recordedCtx: UsageContext[];
|
||||
|
||||
@@ -85,6 +87,7 @@ describe("session-manager", () => {
|
||||
new SessionManager({
|
||||
sessions,
|
||||
channels,
|
||||
sources,
|
||||
loader,
|
||||
recorder: {
|
||||
record: async (ctx, msg) => {
|
||||
@@ -114,6 +117,7 @@ describe("session-manager", () => {
|
||||
sessions = new SessionsRepo(db);
|
||||
sessions.insert(ROW);
|
||||
channels = new ChannelHub();
|
||||
sources = new SessionSources();
|
||||
recorded = [];
|
||||
recordedCtx = [];
|
||||
});
|
||||
@@ -287,6 +291,7 @@ describe("session-manager", () => {
|
||||
const manager = new SessionManager({
|
||||
sessions,
|
||||
channels,
|
||||
sources,
|
||||
loader: loaderOf(fake),
|
||||
recorder: { record: async () => {} },
|
||||
titles: {
|
||||
@@ -301,6 +306,11 @@ describe("session-manager", () => {
|
||||
expect(child?.agentId).toBe("child_agent");
|
||||
expect(child?.modelId).toBe("m-child");
|
||||
expect(child?.workspace).toBe("/tmp/w-child");
|
||||
// The origin lands in the in-process registry (session_meta is the single source of
|
||||
// truth; the row stores no source column) — "subagent" even when the forwarded meta
|
||||
// predates the source field (this fake omits it): the registration path is the fallback.
|
||||
expect(sources.get("child-1")).toBe("subagent");
|
||||
expect(child && "source" in child).toBe(false);
|
||||
// Title left blank: produced by the title generator from the sub-session's own conversation (falls back to the prompt's first line on failure).
|
||||
expect(child?.title).toBeNull();
|
||||
|
||||
@@ -577,6 +587,7 @@ describe("session-manager", () => {
|
||||
const manager = new SessionManager({
|
||||
sessions,
|
||||
channels,
|
||||
sources,
|
||||
loader: loaderOf(plainSession),
|
||||
recorder: { record: async () => {} },
|
||||
titles: {
|
||||
@@ -629,6 +640,7 @@ describe("session-manager", () => {
|
||||
const manager = new SessionManager({
|
||||
sessions,
|
||||
channels,
|
||||
sources,
|
||||
loader: loaderOf(longSession),
|
||||
recorder: { record: async () => {} },
|
||||
titles: {
|
||||
@@ -695,6 +707,7 @@ describe("session-manager", () => {
|
||||
const manager = new SessionManager({
|
||||
sessions,
|
||||
channels,
|
||||
sources,
|
||||
loader: loaderOf(delegating),
|
||||
recorder: {
|
||||
record: async (_ctx, msg) => {
|
||||
|
||||
Reference in new issue
Block a user