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
@@ -126,6 +126,8 @@ export interface CreateSessionOptions {
|
|||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
/** Internal use: this Session's depth in the subagent spawn chain (0 at the top level), used to cap spawn depth. */
|
/** Internal use: this Session's depth in the subagent spawn chain (0 at the top level), used to cap spawn depth. */
|
||||||
subagentDepth?: number;
|
subagentDepth?: number;
|
||||||
|
/** Session origin recorded in session_meta (absent = user-created); the subagent spawn site passes "subagent", callers driven by a scheduled task pass "schedule". */
|
||||||
|
source?: "subagent" | "schedule";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResumeSessionOptions {
|
export interface ResumeSessionOptions {
|
||||||
@@ -298,6 +300,7 @@ export class Agent {
|
|||||||
thinking_level: thinkingLevel ?? "default",
|
thinking_level: thinkingLevel ?? "default",
|
||||||
agent_state: this.state.stateDir,
|
agent_state: this.state.stateDir,
|
||||||
workspace: workspaceDir,
|
workspace: workspaceDir,
|
||||||
|
...(opts.source !== undefined ? { source: opts.source } : {}),
|
||||||
},
|
},
|
||||||
llm: rt.llm,
|
llm: rt.llm,
|
||||||
environment: rt.environment,
|
environment: rt.environment,
|
||||||
@@ -444,6 +447,12 @@ export class Agent {
|
|||||||
thinking_level: thinkingLevel ?? "default",
|
thinking_level: thinkingLevel ?? "default",
|
||||||
agent_state: this.state.stateDir,
|
agent_state: this.state.stateDir,
|
||||||
workspace: workspaceDir,
|
workspace: workspaceDir,
|
||||||
|
// The origin carries over from the original session_meta (a resumed scheduled/subagent
|
||||||
|
// Session stays marked). The on-disk value is untrusted: only the exact known origins
|
||||||
|
// pass; junk written by a third party is dropped rather than cast through.
|
||||||
|
...(meta.source === "subagent" || meta.source === "schedule"
|
||||||
|
? { source: meta.source }
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
llm: rt.llm,
|
llm: rt.llm,
|
||||||
environment: rt.environment,
|
environment: rt.environment,
|
||||||
@@ -573,6 +582,7 @@ export class Agent {
|
|||||||
...childModel,
|
...childModel,
|
||||||
thinkingLevel: thinkingLevel ?? null,
|
thinkingLevel: thinkingLevel ?? null,
|
||||||
subagentDepth: subagentDepth + 1,
|
subagentDepth: subagentDepth + 1,
|
||||||
|
source: "subagent",
|
||||||
});
|
});
|
||||||
// All child-session messages are tagged with an origin (the child Session id,
|
// All child-session messages are tagged with an origin (the child Session id,
|
||||||
// prepended as one hop from outer to inner); the first turn forwards the
|
// prepended as one hop from outer to inner); the first turn forwards the
|
||||||
|
|||||||
@@ -92,6 +92,8 @@ export interface SessionMetaPayload {
|
|||||||
agent_state: string;
|
agent_state: string;
|
||||||
/** Absolute path to the Workspace. */
|
/** Absolute path to the Workspace. */
|
||||||
workspace: string;
|
workspace: string;
|
||||||
|
/** Session origin: spawned by a subagent / triggered by a scheduled task; absent = user-created. */
|
||||||
|
source?: "subagent" | "schedule";
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -220,6 +220,31 @@ describe("Agent.createSession model reference ((provider, model_id) pair)", () =
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Agent.createSession session source (session_meta origin marker)", () => {
|
||||||
|
it("records the given source in session_meta; a user-created session carries no source key", async () => {
|
||||||
|
const agent = await createAgent();
|
||||||
|
const ws = path.join(tmpRoot, "ws-source");
|
||||||
|
await fs.mkdir(ws, { recursive: true });
|
||||||
|
|
||||||
|
const scheduled = await agent.createSession({ workspaceDir: ws, source: "schedule" });
|
||||||
|
try {
|
||||||
|
expect((scheduled.metaMessage.payload as { source?: string }).source).toBe("schedule");
|
||||||
|
} finally {
|
||||||
|
scheduled.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Absent = user-created: the key must not appear at all (Trace consumers treat absence as the default).
|
||||||
|
const plain = await agent.createSession({ workspaceDir: ws });
|
||||||
|
try {
|
||||||
|
expect("source" in (plain.metaMessage.payload as unknown as Record<string, unknown>)).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
plain.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("Agent.createSession thinking level (explicit option wins over the Agent config)", () => {
|
describe("Agent.createSession thinking level (explicit option wins over the Agent config)", () => {
|
||||||
const uniThinkingOf = (llm: unknown): unknown =>
|
const uniThinkingOf = (llm: unknown): unknown =>
|
||||||
((llm as { uniConfig?: { thinking_level?: unknown } }).uniConfig ?? {}).thinking_level;
|
((llm as { uniConfig?: { thinking_level?: unknown } }).uniConfig ?? {}).thinking_level;
|
||||||
@@ -276,7 +301,13 @@ describe("run_subagent spawning follows the PARENT session (never the Project de
|
|||||||
async function spawnedChildMeta(
|
async function spawnedChildMeta(
|
||||||
runner: SubagentRunner,
|
runner: SubagentRunner,
|
||||||
input: Parameters<SubagentRunner["spawn"]>[0],
|
input: Parameters<SubagentRunner["spawn"]>[0],
|
||||||
): Promise<{ provider: string; model_id: string; thinking_level: string; workspace: string }> {
|
): Promise<{
|
||||||
|
provider: string;
|
||||||
|
model_id: string;
|
||||||
|
thinking_level: string;
|
||||||
|
workspace: string;
|
||||||
|
source?: string;
|
||||||
|
}> {
|
||||||
const handle = await runner.spawn(input);
|
const handle = await runner.spawn(input);
|
||||||
try {
|
try {
|
||||||
const gen = handle.run({ prompt: "noop" });
|
const gen = handle.run({ prompt: "noop" });
|
||||||
@@ -292,6 +323,7 @@ describe("run_subagent spawning follows the PARENT session (never the Project de
|
|||||||
model_id: string;
|
model_id: string;
|
||||||
thinking_level: string;
|
thinking_level: string;
|
||||||
workspace: string;
|
workspace: string;
|
||||||
|
source?: string;
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
handle.dispose();
|
handle.dispose();
|
||||||
@@ -320,6 +352,9 @@ describe("run_subagent spawning follows the PARENT session (never the Project de
|
|||||||
expect(child.thinking_level).toBe("high");
|
expect(child.thinking_level).toBe("high");
|
||||||
// Workspace inheritance (behavior that predates model/thinking inheritance): locked here.
|
// Workspace inheritance (behavior that predates model/thinking inheritance): locked here.
|
||||||
expect(child.workspace).toBe(ws);
|
expect(child.workspace).toBe(ws);
|
||||||
|
// The spawn site marks the child's own session_meta as subagent-created — the single
|
||||||
|
// source of truth the server derives from (its registration fallback cannot mask this).
|
||||||
|
expect(child.source).toBe("subagent");
|
||||||
} finally {
|
} finally {
|
||||||
parent.dispose();
|
parent.dispose();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,7 +77,12 @@ async function writeTraceFile(
|
|||||||
return file;
|
return file;
|
||||||
}
|
}
|
||||||
|
|
||||||
function metaFor(sessionId: string, workspaceDir: string, model = MODEL): OmniMessage {
|
function metaFor(
|
||||||
|
sessionId: string,
|
||||||
|
workspaceDir: string,
|
||||||
|
model = MODEL,
|
||||||
|
source?: "subagent" | "schedule",
|
||||||
|
): OmniMessage {
|
||||||
return sessionMeta({
|
return sessionMeta({
|
||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
provider: model.provider,
|
provider: model.provider,
|
||||||
@@ -88,6 +93,7 @@ function metaFor(sessionId: string, workspaceDir: string, model = MODEL): OmniMe
|
|||||||
thinking_level: "default",
|
thinking_level: "default",
|
||||||
agent_state: "/agent/state",
|
agent_state: "/agent/state",
|
||||||
workspace: workspaceDir,
|
workspace: workspaceDir,
|
||||||
|
...(source !== undefined ? { source } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +122,43 @@ describe("agent.resumeSession", () => {
|
|||||||
expect(texts).toEqual(["hello", "hi there"]);
|
expect(texts).toEqual(["hello", "hi there"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves the stored session source across resume (and its absence for user sessions)", async () => {
|
||||||
|
const agent = await createAgent({});
|
||||||
|
await writeTraceFile(tmpRoot, SID, [
|
||||||
|
metaFor(SID, workspace, MODEL, "subagent"),
|
||||||
|
userText("child task"),
|
||||||
|
requestBegin(),
|
||||||
|
assistantText("done"),
|
||||||
|
requestEnd("completed"),
|
||||||
|
]);
|
||||||
|
const session = await agent.resumeSession({ sessionId: SID });
|
||||||
|
// The rebuilt session_meta carries the origin over (a rotated Trace file must re-record it).
|
||||||
|
expect((session.metaMessage.payload as { source?: string }).source).toBe("subagent");
|
||||||
|
session.dispose();
|
||||||
|
|
||||||
|
// A user-created session's meta has no source key, and resume must not invent one.
|
||||||
|
const SID2 = "session-2026-07-06-11-00-00-abcdef02";
|
||||||
|
await writeTraceFile(tmpRoot, SID2, [metaFor(SID2, workspace), userText("hi")]);
|
||||||
|
const plain = await agent.resumeSession({ sessionId: SID2 });
|
||||||
|
expect("source" in (plain.metaMessage.payload as unknown as Record<string, unknown>)).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
plain.dispose();
|
||||||
|
|
||||||
|
// On-disk values are untrusted: a junk source written by a third party is dropped on
|
||||||
|
// resume (only the exact known origins pass), not cast through into the rebuilt meta.
|
||||||
|
const SID3 = "session-2026-07-06-12-00-00-abcdef03";
|
||||||
|
await writeTraceFile(tmpRoot, SID3, [
|
||||||
|
metaFor(SID3, workspace, MODEL, "weird-origin" as unknown as "subagent"),
|
||||||
|
userText("hi"),
|
||||||
|
]);
|
||||||
|
const junk = await agent.resumeSession({ sessionId: SID3 });
|
||||||
|
expect("source" in (junk.metaMessage.payload as unknown as Record<string, unknown>)).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
junk.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps abort events in resumed render history", async () => {
|
it("keeps abort events in resumed render history", async () => {
|
||||||
const agent = await createAgent({});
|
const agent = await createAgent({});
|
||||||
await writeTraceFile(tmpRoot, SID, [
|
await writeTraceFile(tmpRoot, SID, [
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ interface SessionMetaPayload {
|
|||||||
thinking_level: string; // "default" when unconfigured
|
thinking_level: string; // "default" when unconfigured
|
||||||
agent_state: string; // absolute path of the Agent State
|
agent_state: string; // absolute path of the Agent State
|
||||||
workspace: string; // absolute path of the Workspace
|
workspace: string; // absolute path of the Workspace
|
||||||
|
source?: "subagent" | "schedule"; // session origin; absent = user-created
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ToolDefinition {
|
interface ToolDefinition {
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ interface SessionMetaPayload {
|
|||||||
thinking_level: string; // 未配置时为 "default"
|
thinking_level: string; // 未配置时为 "default"
|
||||||
agent_state: string; // Agent State 绝对路径
|
agent_state: string; // Agent State 绝对路径
|
||||||
workspace: string; // Workspace 绝对路径
|
workspace: string; // Workspace 绝对路径
|
||||||
|
source?: "subagent" | "schedule"; // Session 来源;缺省 = 用户创建
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ToolDefinition {
|
interface ToolDefinition {
|
||||||
|
|||||||
@@ -454,7 +454,7 @@ export interface SessionInfo {
|
|||||||
approvalMode: ApprovalMode;
|
approvalMode: ApprovalMode;
|
||||||
/** Short title auto-generated by the model after the first turn; unset until generated (frontend shows "New Chat"). */
|
/** Short title auto-generated by the model after the first turn; unset until generated (frontend shows "New Chat"). */
|
||||||
title?: string;
|
title?: string;
|
||||||
/** Session source (for list badges); unset for user-created sessions. */
|
/** Session source (for list badges/folders), derived from core session_meta — the single source of truth (not stored in the DB); unset for user-created sessions. */
|
||||||
source?: SessionSource;
|
source?: SessionSource;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
status: SessionStatus;
|
status: SessionStatus;
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ import { agentSessionsRoutes, sessionsRoutes } from "./http/routes/sessions.js";
|
|||||||
import { ChannelHub } from "./runtime/channel.js";
|
import { ChannelHub } from "./runtime/channel.js";
|
||||||
import { ErrorRecorder } from "./runtime/error-recorder.js";
|
import { ErrorRecorder } from "./runtime/error-recorder.js";
|
||||||
import { createCoreSessionLoader, SessionManager } from "./runtime/session-manager.js";
|
import { createCoreSessionLoader, SessionManager } from "./runtime/session-manager.js";
|
||||||
|
import { SessionSources } from "./runtime/session-sources.js";
|
||||||
import type { SessionLoader } from "./runtime/session-manager.js";
|
import type { SessionLoader } from "./runtime/session-manager.js";
|
||||||
import { Scheduler } from "./runtime/scheduler.js";
|
import { Scheduler } from "./runtime/scheduler.js";
|
||||||
import { TitleGenerator } from "./runtime/title-generator.js";
|
import { TitleGenerator } from "./runtime/title-generator.js";
|
||||||
@@ -91,6 +92,8 @@ export interface AppDeps {
|
|||||||
scheduler: Scheduler;
|
scheduler: Scheduler;
|
||||||
channels: ChannelHub;
|
channels: ChannelHub;
|
||||||
manager: SessionManager;
|
manager: SessionManager;
|
||||||
|
/** Session-origin registry derived from session_meta (single source of truth; no DB column). */
|
||||||
|
sessionSources: SessionSources;
|
||||||
/** Error persistence (shared by app.onError and various background capture points; the process-level fallback is in index.ts). */
|
/** Error persistence (shared by app.onError and various background capture points; the process-level fallback is in index.ts). */
|
||||||
errors: ErrorRecorder;
|
errors: ErrorRecorder;
|
||||||
/** Request log output (minimal one-liner); tests inject a noop. */
|
/** Request log output (minimal one-liner); tests inject a noop. */
|
||||||
@@ -149,10 +152,15 @@ export function buildAppDeps(config: ServerConfig, overrides: BuildDepsOverrides
|
|||||||
const titles =
|
const titles =
|
||||||
overrides.titles ??
|
overrides.titles ??
|
||||||
new TitleGenerator({ sessions: sessionsRepo, channels, recorder, errors, log });
|
new TitleGenerator({ sessions: sessionsRepo, channels, recorder, errors, log });
|
||||||
|
// Session-origin registry: session_meta is the single source of truth (no DB column);
|
||||||
|
// shared by the manager (subagent registration), the loader (self-heal rebuild) and
|
||||||
|
// SessionService (creation / adoption / lazy list resolution).
|
||||||
|
const sessionSources = new SessionSources();
|
||||||
const manager = new SessionManager({
|
const manager = new SessionManager({
|
||||||
sessions: sessionsRepo,
|
sessions: sessionsRepo,
|
||||||
channels,
|
channels,
|
||||||
loader: overrides.loader ?? createCoreSessionLoader(config.root),
|
loader: overrides.loader ?? createCoreSessionLoader(config.root, sessionSources),
|
||||||
|
sources: sessionSources,
|
||||||
recorder,
|
recorder,
|
||||||
errors,
|
errors,
|
||||||
titles,
|
titles,
|
||||||
@@ -194,6 +202,7 @@ export function buildAppDeps(config: ServerConfig, overrides: BuildDepsOverrides
|
|||||||
sessions: sessionsRepo,
|
sessions: sessionsRepo,
|
||||||
manager,
|
manager,
|
||||||
projectConfig: projectConfigService,
|
projectConfig: projectConfigService,
|
||||||
|
sources: sessionSources,
|
||||||
});
|
});
|
||||||
// Schedule scheduler: active only while the server is running. Only
|
// Schedule scheduler: active only while the server is running. Only
|
||||||
// assembled here; start() is called in index.ts (tests drive it via tickOnce, no real timer).
|
// assembled here; start() is called in index.ts (tests drive it via tickOnce, no real timer).
|
||||||
@@ -232,6 +241,7 @@ export function buildAppDeps(config: ServerConfig, overrides: BuildDepsOverrides
|
|||||||
scheduler,
|
scheduler,
|
||||||
channels,
|
channels,
|
||||||
manager,
|
manager,
|
||||||
|
sessionSources,
|
||||||
errors,
|
errors,
|
||||||
log,
|
log,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ export interface SessionRow {
|
|||||||
title: string | null;
|
title: string | null;
|
||||||
/** Archive timestamp, ISO; NULL = not archived (omitting on insert defaults to NULL). */
|
/** Archive timestamp, ISO; NULL = not archived (omitting on insert defaults to NULL). */
|
||||||
archivedAt?: string | null;
|
archivedAt?: string | null;
|
||||||
/** Session origin: NULL = user-created; schedule = triggered by a Schedule; subagent = registered as a subagent session. */
|
// The Session origin (schedule / subagent) is deliberately NOT a row field: core
|
||||||
source?: "schedule" | "subagent" | null;
|
// session_meta in the Trace is the single source of truth (runtime/session-sources.ts).
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +35,6 @@ function mapRow(r: Record<string, unknown>): SessionRow {
|
|||||||
approvalMode: r.approval_mode as ApprovalMode,
|
approvalMode: r.approval_mode as ApprovalMode,
|
||||||
title: (r.title as string | null) ?? null,
|
title: (r.title as string | null) ?? null,
|
||||||
archivedAt: (r.archived_at as string | null) ?? null,
|
archivedAt: (r.archived_at as string | null) ?? null,
|
||||||
source: (r.source as "schedule" | "subagent" | null) ?? null,
|
|
||||||
createdAt: r.created_at as string,
|
createdAt: r.created_at as string,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -46,8 +45,8 @@ export class SessionsRepo {
|
|||||||
insert(row: SessionRow): void {
|
insert(row: SessionRow): void {
|
||||||
this.db
|
this.db
|
||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO sessions (session_id, project_id, agent_id, provider, model_id, workspace, approval_mode, title, source, created_at)
|
`INSERT INTO sessions (session_id, project_id, agent_id, provider, model_id, workspace, approval_mode, title, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
)
|
)
|
||||||
.run(
|
.run(
|
||||||
row.sessionId,
|
row.sessionId,
|
||||||
@@ -58,7 +57,6 @@ export class SessionsRepo {
|
|||||||
row.workspace,
|
row.workspace,
|
||||||
row.approvalMode,
|
row.approvalMode,
|
||||||
row.title,
|
row.title,
|
||||||
row.source ?? null,
|
|
||||||
row.createdAt,
|
row.createdAt,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -67,8 +65,8 @@ export class SessionsRepo {
|
|||||||
insertOrIgnore(row: SessionRow): void {
|
insertOrIgnore(row: SessionRow): void {
|
||||||
this.db
|
this.db
|
||||||
.prepare(
|
.prepare(
|
||||||
`INSERT OR IGNORE INTO sessions (session_id, project_id, agent_id, provider, model_id, workspace, approval_mode, title, source, created_at)
|
`INSERT OR IGNORE INTO sessions (session_id, project_id, agent_id, provider, model_id, workspace, approval_mode, title, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
)
|
)
|
||||||
.run(
|
.run(
|
||||||
row.sessionId,
|
row.sessionId,
|
||||||
@@ -79,7 +77,6 @@ export class SessionsRepo {
|
|||||||
row.workspace,
|
row.workspace,
|
||||||
row.approvalMode,
|
row.approvalMode,
|
||||||
row.title,
|
row.title,
|
||||||
row.source ?? null,
|
|
||||||
row.createdAt,
|
row.createdAt,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,9 +48,8 @@ CREATE TABLE IF NOT EXISTS sessions (
|
|||||||
approval_mode TEXT NOT NULL DEFAULT 'allow-all', -- allow-all|deny-all|read-only|always-ask
|
approval_mode TEXT NOT NULL DEFAULT 'allow-all', -- allow-all|deny-all|read-only|always-ask
|
||||||
title TEXT, -- auto-generated by the model after the first exchange; NULL=not yet (frontend shows "New chat")
|
title TEXT, -- auto-generated by the model after the first exchange; NULL=not yet (frontend shows "New chat")
|
||||||
archived_at TEXT, -- archive time; NULL=not archived (shown by default; archived ones move under "Archived")
|
archived_at TEXT, -- archive time; NULL=not archived (shown by default; archived ones move under "Archived")
|
||||||
source TEXT, -- session origin: NULL=user-created | schedule (scheduled task) | subagent (child session)
|
|
||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL
|
||||||
);
|
); -- session origin is NOT stored: session_meta in the Trace is the single source of truth (see runtime/session-sources.ts)
|
||||||
CREATE TABLE IF NOT EXISTS usage_records (
|
CREATE TABLE IF NOT EXISTS usage_records (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
ts TEXT NOT NULL,
|
ts TEXT NOT NULL,
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { sseEndpoint } from "../sse.js";
|
|||||||
import {
|
import {
|
||||||
badRequest,
|
badRequest,
|
||||||
optionalEnum,
|
optionalEnum,
|
||||||
|
optionalPagingQuery,
|
||||||
optionalString,
|
optionalString,
|
||||||
paginationQuery,
|
paginationQuery,
|
||||||
pathParam,
|
pathParam,
|
||||||
@@ -96,7 +97,14 @@ export function agentSessionsRoutes(deps: AppDeps): Hono<AppEnv> {
|
|||||||
const agentId = requireValidId(c, "agentId");
|
const agentId = requireValidId(c, "agentId");
|
||||||
deps.projectService.requireProjectAccess(c.var.user.userId, projectId);
|
deps.projectService.requireProjectAccess(c.var.user.userId, projectId);
|
||||||
await deps.agentConfigService.requireExists(projectId, agentId);
|
await deps.agentConfigService.requireExists(projectId, agentId);
|
||||||
const sessions = await deps.sessionService.listSessions(projectId, agentId);
|
// Optional paging (absent = full list, the pre-paging contract): the sidebar requests
|
||||||
|
// limit+1 and shows limit, detecting "has more" without a response-envelope change.
|
||||||
|
const paging = optionalPagingQuery(c);
|
||||||
|
const sessions = await deps.sessionService.listSessions(
|
||||||
|
projectId,
|
||||||
|
agentId,
|
||||||
|
...(paging ? [paging] : []),
|
||||||
|
);
|
||||||
return c.json({ sessions } satisfies SessionsResponse);
|
return c.json({ sessions } satisfies SessionsResponse);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -167,7 +175,9 @@ export function sessionsRoutes(deps: AppDeps): Hono<AppEnv> {
|
|||||||
app.get("/:sessionId", async (c) => {
|
app.get("/:sessionId", async (c) => {
|
||||||
const row = resolveSession(c);
|
const row = resolveSession(c);
|
||||||
const hasTrace = await deps.sessionService.hasTrace(row);
|
const hasTrace = await deps.sessionService.hasTrace(row);
|
||||||
return c.json({ session: deps.sessionService.toInfo(row, hasTrace) } satisfies SessionResponse);
|
return c.json({
|
||||||
|
session: await deps.sessionService.toInfo(row, hasTrace),
|
||||||
|
} satisfies SessionResponse);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.patch("/:sessionId", async (c) => {
|
app.patch("/:sessionId", async (c) => {
|
||||||
@@ -216,7 +226,7 @@ export function sessionsRoutes(deps: AppDeps): Hono<AppEnv> {
|
|||||||
}
|
}
|
||||||
const hasTrace = await deps.sessionService.hasTrace(updated);
|
const hasTrace = await deps.sessionService.hasTrace(updated);
|
||||||
return c.json({
|
return c.json({
|
||||||
session: deps.sessionService.toInfo(updated, hasTrace),
|
session: await deps.sessionService.toInfo(updated, hasTrace),
|
||||||
} satisfies SessionResponse);
|
} satisfies SessionResponse);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -244,6 +254,9 @@ export function sessionsRoutes(deps: AppDeps): Hono<AppEnv> {
|
|||||||
{ recursive: true, force: true },
|
{ recursive: true, force: true },
|
||||||
);
|
);
|
||||||
deps.sessionsRepo.deleteById(row.sessionId);
|
deps.sessionsRepo.deleteById(row.sessionId);
|
||||||
|
// Drop the derived-origin entry along with the Session (bulk Agent/Project deletion
|
||||||
|
// may leave stale entries; session ids are never reused, so they are never matched).
|
||||||
|
deps.sessionSources.delete(row.sessionId);
|
||||||
} finally {
|
} finally {
|
||||||
deps.manager.endSessionDeletion(row.sessionId);
|
deps.manager.endSessionDeletion(row.sessionId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,28 @@ export function paginationQuery(c: Context): { offset: number; limit: number } {
|
|||||||
return { offset, limit };
|
return { offset, limit };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse OPTIONAL list paging query params: both absent = null (caller returns the full
|
||||||
|
* list — the pre-paging behavior stays intact for existing callers). When paging, `limit`
|
||||||
|
* is required (1-1000) and `offset` optional (>= 0, default 0) — an offset alone would
|
||||||
|
* silently return the full list shifted, which no caller ever means.
|
||||||
|
*/
|
||||||
|
export function optionalPagingQuery(c: Context): { offset: number; limit: number } | null {
|
||||||
|
const rawLimit = c.req.query("limit");
|
||||||
|
const rawOffset = c.req.query("offset");
|
||||||
|
if (rawLimit === undefined && rawOffset === undefined) return null;
|
||||||
|
if (rawLimit === undefined) throw badRequest("offset requires limit.");
|
||||||
|
const limit = Number.parseInt(rawLimit, 10);
|
||||||
|
if (!Number.isInteger(limit) || limit < 1 || limit > 1000) {
|
||||||
|
throw badRequest("limit must be an integer between 1 and 1000.");
|
||||||
|
}
|
||||||
|
const offset = Number.parseInt(rawOffset ?? "0", 10);
|
||||||
|
if (!Number.isInteger(offset) || offset < 0) {
|
||||||
|
throw badRequest("offset must be a non-negative integer.");
|
||||||
|
}
|
||||||
|
return { offset, limit };
|
||||||
|
}
|
||||||
|
|
||||||
/** Read the JSON request body (parse failure / non-object -> 400). */
|
/** Read the JSON request body (parse failure / non-object -> 400). */
|
||||||
export async function readJson(c: Context): Promise<Record<string, unknown>> {
|
export async function readJson(c: Context): Promise<Record<string, unknown>> {
|
||||||
let body: unknown;
|
let body: unknown;
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ import { ApprovalRegistry, makeApprove } from "./approvals.js";
|
|||||||
import type { PendingApproval } from "./approvals.js";
|
import type { PendingApproval } from "./approvals.js";
|
||||||
import type { ChannelHub } from "./channel.js";
|
import type { ChannelHub } from "./channel.js";
|
||||||
import type { ErrorSink } from "./error-recorder.js";
|
import type { ErrorSink } from "./error-recorder.js";
|
||||||
|
import { asSessionSource } from "./session-sources.js";
|
||||||
|
import type { SessionSources } from "./session-sources.js";
|
||||||
import { StreamErrorWatcher } from "./stream-error-watcher.js";
|
import { StreamErrorWatcher } from "./stream-error-watcher.js";
|
||||||
import type { TitleNotifier } from "./title-generator.js";
|
import type { TitleNotifier } from "./title-generator.js";
|
||||||
import type { UsageContext } from "./usage-recorder.js";
|
import type { UsageContext } from "./usage-recorder.js";
|
||||||
@@ -96,8 +98,13 @@ export interface SessionLoader {
|
|||||||
load(row: SessionRow): Promise<RuntimeSession>;
|
load(row: SessionRow): Promise<RuntimeSession>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Production loader: the core SDK's resumeSession / createSession. */
|
/**
|
||||||
export function createCoreSessionLoader(root: string): SessionLoader {
|
* Production loader: the core SDK's resumeSession / createSession. `sources` (when given)
|
||||||
|
* lets the no-Trace self-heal rebuild re-record a known origin into the fresh session_meta;
|
||||||
|
* with no registry entry (e.g. the process restarted and no Trace was ever written) the
|
||||||
|
* rebuilt Session is unsourced — session_meta is the single source of truth, and none survived.
|
||||||
|
*/
|
||||||
|
export function createCoreSessionLoader(root: string, sources?: SessionSources): SessionLoader {
|
||||||
return {
|
return {
|
||||||
async load(row: SessionRow): Promise<RuntimeSession> {
|
async load(row: SessionRow): Promise<RuntimeSession> {
|
||||||
const agent = await createAgent({
|
const agent = await createAgent({
|
||||||
@@ -136,11 +143,14 @@ export function createCoreSessionLoader(root: string): SessionLoader {
|
|||||||
`This Session's Workspace no longer exists: ${row.workspace}, so it cannot continue. Create a new Session.`,
|
`This Session's Workspace no longer exists: ${row.workspace}, so it cannot continue. Create a new Session.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const knownSource = sources?.get(row.sessionId);
|
||||||
try {
|
try {
|
||||||
return await agent.createSession({
|
return await agent.createSession({
|
||||||
workspaceDir: row.workspace,
|
workspaceDir: row.workspace,
|
||||||
modelId: row.modelId,
|
modelId: row.modelId,
|
||||||
provider: row.provider,
|
provider: row.provider,
|
||||||
|
// The rebuilt Session re-records a known origin in its fresh session_meta.
|
||||||
|
...(knownSource != null ? { source: knownSource } : {}),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isMissingCredential(err)) throw modelCredentialMissing(row.modelId);
|
if (isMissingCredential(err)) throw modelCredentialMissing(row.modelId);
|
||||||
@@ -168,6 +178,8 @@ export interface SessionManagerDeps {
|
|||||||
sessions: SessionsRepo;
|
sessions: SessionsRepo;
|
||||||
channels: ChannelHub;
|
channels: ChannelHub;
|
||||||
loader: SessionLoader;
|
loader: SessionLoader;
|
||||||
|
/** Session-origin registry (session_meta is the single source of truth; subagent registration records the forwarded meta's source here). */
|
||||||
|
sources: SessionSources;
|
||||||
recorder: UsageRecorderLike;
|
recorder: UsageRecorderLike;
|
||||||
/** Automatic Session title generation (optional: not injected in tests or when disabled). */
|
/** Automatic Session title generation (optional: not injected in tests or when disabled). */
|
||||||
titles?: TitleNotifier;
|
titles?: TitleNotifier;
|
||||||
@@ -879,6 +891,12 @@ export class SessionManager {
|
|||||||
const p = msg.payload as SessionMetaPayload;
|
const p = msg.payload as SessionMetaPayload;
|
||||||
const agentId = path.basename(path.dirname(p.agent_state));
|
const agentId = path.basename(path.dirname(p.agent_state));
|
||||||
if (!agentId || agentId === "." || agentId === "..") return null;
|
if (!agentId || agentId === "." || agentId === "..") return null;
|
||||||
|
// The forwarded session_meta records the origin at the source (core's spawn site); fall
|
||||||
|
// back to inferring "subagent" from the registration path for older metas (narrowed —
|
||||||
|
// a junk value also falls back). It goes into the in-process registry only — the index
|
||||||
|
// row deliberately stores no source column.
|
||||||
|
const source = asSessionSource(p.source) ?? "subagent";
|
||||||
|
this.deps.sources.set(childSid, source);
|
||||||
this.deps.sessions.insertOrIgnore({
|
this.deps.sessions.insertOrIgnore({
|
||||||
sessionId: childSid,
|
sessionId: childSid,
|
||||||
projectId: entry.projectId,
|
projectId: entry.projectId,
|
||||||
@@ -890,7 +908,6 @@ export class SessionManager {
|
|||||||
// inserted with defaults (matches the convention for Sessions discovered by the CLI).
|
// inserted with defaults (matches the convention for Sessions discovered by the CLI).
|
||||||
approvalMode: "allow-all",
|
approvalMode: "allow-all",
|
||||||
title: null,
|
title: null,
|
||||||
source: "subagent",
|
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
// Make the subagent appear immediately in the sidebar: notify via the parent
|
// Make the subagent appear immediately in the sidebar: notify via the parent
|
||||||
@@ -900,7 +917,7 @@ export class SessionManager {
|
|||||||
projectId: entry.projectId,
|
projectId: entry.projectId,
|
||||||
agentId,
|
agentId,
|
||||||
sessionId: childSid,
|
sessionId: childSid,
|
||||||
source: "subagent",
|
source,
|
||||||
});
|
});
|
||||||
const child: ChildSession = {
|
const child: ChildSession = {
|
||||||
sessionId: childSid,
|
sessionId: childSid,
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* In-process registry of Session origins, derived from core `session_meta` — the single
|
||||||
|
* source of truth for a Session's origin (the DB stores no `source` column).
|
||||||
|
*
|
||||||
|
* Populated wherever the server actually has the meta in hand: Session creation
|
||||||
|
* (SessionService reads the just-created core Session's meta), subagent registration
|
||||||
|
* (SessionManager reads the forwarded child meta), and Trace adoption / lazy list
|
||||||
|
* resolution (SessionService reads the Trace head's session_meta). `null` records a
|
||||||
|
* **known** user-created Session (meta seen, no source) so the Trace is not re-read on
|
||||||
|
* every list; an absent entry means "unknown" and the list path resolves it from the
|
||||||
|
* Trace once per process lifetime.
|
||||||
|
*/
|
||||||
|
import type { SessionSource } from "../api/types.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Narrows an untrusted value (on-disk Trace JSON / forwarded meta) to a SessionSource:
|
||||||
|
* only the exact known origins pass; anything else — including junk written by third
|
||||||
|
* parties — is treated as absent rather than cast through.
|
||||||
|
*/
|
||||||
|
export function asSessionSource(v: unknown): SessionSource | undefined {
|
||||||
|
return v === "schedule" || v === "subagent" ? v : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SessionSources {
|
||||||
|
private readonly map = new Map<string, SessionSource | null>();
|
||||||
|
|
||||||
|
/** Records a Session's origin as read from session_meta (`null` = meta seen, user-created). */
|
||||||
|
set(sessionId: string, source: SessionSource | null): void {
|
||||||
|
this.map.set(sessionId, source);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Known origin, `null` for a known user-created Session, `undefined` when this process has not seen the meta. */
|
||||||
|
get(sessionId: string): SessionSource | null | undefined {
|
||||||
|
return this.map.get(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drops a deleted Session's entry (bulk Agent/Project deletion may leave stale entries; they are never matched again). */
|
||||||
|
delete(sessionId: string): void {
|
||||||
|
this.map.delete(sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,11 +20,14 @@ import {
|
|||||||
readTraceTolerant,
|
readTraceTolerant,
|
||||||
tracesDir,
|
tracesDir,
|
||||||
} from "@prismshadow/penguin-core";
|
} from "@prismshadow/penguin-core";
|
||||||
import type { ApprovalMode, SessionInfo } from "../api/types.js";
|
import type { SessionMetaMessage } from "@prismshadow/penguin-core";
|
||||||
|
import type { ApprovalMode, SessionInfo, SessionSource } from "../api/types.js";
|
||||||
import { HttpError, isMissingCredential, modelCredentialMissing } from "../http/errors.js";
|
import { HttpError, isMissingCredential, modelCredentialMissing } from "../http/errors.js";
|
||||||
import { badRequest } from "../http/validate.js";
|
import { badRequest } from "../http/validate.js";
|
||||||
import type { SessionRow, SessionsRepo } from "../db/repos/sessions.js";
|
import type { SessionRow, SessionsRepo } from "../db/repos/sessions.js";
|
||||||
import type { SessionManager } from "../runtime/session-manager.js";
|
import type { SessionManager } from "../runtime/session-manager.js";
|
||||||
|
import { asSessionSource } from "../runtime/session-sources.js";
|
||||||
|
import type { SessionSources } from "../runtime/session-sources.js";
|
||||||
import type { ProjectConfigService } from "./project-config-service.js";
|
import type { ProjectConfigService } from "./project-config-service.js";
|
||||||
|
|
||||||
const TRACE_FILE_RE = /^(.+)_(\d{3})\.jsonl$/;
|
const TRACE_FILE_RE = /^(.+)_(\d{3})\.jsonl$/;
|
||||||
@@ -44,13 +47,20 @@ export interface SessionServiceDeps {
|
|||||||
sessions: SessionsRepo;
|
sessions: SessionsRepo;
|
||||||
manager: SessionManager;
|
manager: SessionManager;
|
||||||
projectConfig: ProjectConfigService;
|
projectConfig: ProjectConfigService;
|
||||||
|
/** In-process origin registry derived from session_meta (the DB stores no source column). */
|
||||||
|
sources: SessionSources;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SessionService {
|
export class SessionService {
|
||||||
constructor(private readonly deps: SessionServiceDeps) {}
|
constructor(private readonly deps: SessionServiceDeps) {}
|
||||||
|
|
||||||
/** DB row -> SessionInfo (run status and pending approval count come from session-manager). */
|
/**
|
||||||
toInfo(row: SessionRow, hasTrace: boolean): SessionInfo {
|
* DB row -> SessionInfo (run status and pending approval count come from session-manager).
|
||||||
|
* Async because `source` is derived from session_meta: a registry miss (Session predating
|
||||||
|
* this process) falls back to reading the Trace head once (see sourceOf).
|
||||||
|
*/
|
||||||
|
async toInfo(row: SessionRow, hasTrace: boolean): Promise<SessionInfo> {
|
||||||
|
const source = await this.sourceOf(row, hasTrace);
|
||||||
return {
|
return {
|
||||||
sessionId: row.sessionId,
|
sessionId: row.sessionId,
|
||||||
projectId: row.projectId,
|
projectId: row.projectId,
|
||||||
@@ -60,7 +70,7 @@ export class SessionService {
|
|||||||
workspace: row.workspace,
|
workspace: row.workspace,
|
||||||
approvalMode: row.approvalMode,
|
approvalMode: row.approvalMode,
|
||||||
...(row.title !== null ? { title: row.title } : {}),
|
...(row.title !== null ? { title: row.title } : {}),
|
||||||
...(row.source != null ? { source: row.source } : {}),
|
...(source !== undefined ? { source } : {}),
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
status: this.deps.manager.statusOf(row.sessionId),
|
status: this.deps.manager.statusOf(row.sessionId),
|
||||||
pendingApprovalCount: this.deps.manager.pendingApprovalCount(row.sessionId),
|
pendingApprovalCount: this.deps.manager.pendingApprovalCount(row.sessionId),
|
||||||
@@ -69,14 +79,43 @@ export class SessionService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Session's origin, with session_meta as the single source of truth: the in-process
|
||||||
|
* registry answers first (populated at creation / subagent registration / adoption);
|
||||||
|
* on a miss (a Session created before this process started) the earliest Trace shard's
|
||||||
|
* session_meta is read once and cached. A Session with no Trace yet stays unknown and
|
||||||
|
* is NOT cached negatively — its meta may appear with the first run.
|
||||||
|
*/
|
||||||
|
private async sourceOf(row: SessionRow, hasTrace: boolean): Promise<SessionSource | undefined> {
|
||||||
|
const known = this.deps.sources.get(row.sessionId);
|
||||||
|
if (known !== undefined) return known ?? undefined;
|
||||||
|
if (!hasTrace) return undefined;
|
||||||
|
const meta = await this.readTraceMeta(row.projectId, row.agentId, row.sessionId);
|
||||||
|
if (!meta) return undefined; // Unreadable/corrupt Trace: stay unknown, retry on the next list.
|
||||||
|
// On-disk values are untrusted: only the exact known origins pass, junk = user-created.
|
||||||
|
const source = asSessionSource(meta.payload.source) ?? null;
|
||||||
|
this.deps.sources.set(row.sessionId, source);
|
||||||
|
return source ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/** Whether this Session already has a Trace record (a Task has been run). */
|
/** Whether this Session already has a Trace record (a Task has been run). */
|
||||||
async hasTrace(row: SessionRow): Promise<boolean> {
|
async hasTrace(row: SessionRow): Promise<boolean> {
|
||||||
const ids = await this.discoverTraceSessionIds(row.projectId, row.agentId);
|
const ids = await this.discoverTraceSessionIds(row.projectId, row.agentId);
|
||||||
return ids.has(row.sessionId);
|
return ids.has(row.sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** List: DB ∪ Trace directory discovery, sorted by createdAt descending. */
|
/**
|
||||||
async listSessions(projectId: string, agentId: string): Promise<SessionInfo[]> {
|
* List: DB ∪ Trace directory discovery, sorted by createdAt descending. Optional
|
||||||
|
* `paging` returns just that slice (the sidebar pages with limit+1 to detect "has
|
||||||
|
* more"); slicing happens before toInfo, so per-request source derivation (lazy
|
||||||
|
* Trace-head reads) stays bounded by the page size. Discovery/adoption still scans
|
||||||
|
* the whole directory — the union and global ordering need every id.
|
||||||
|
*/
|
||||||
|
async listSessions(
|
||||||
|
projectId: string,
|
||||||
|
agentId: string,
|
||||||
|
paging?: { offset: number; limit: number },
|
||||||
|
): Promise<SessionInfo[]> {
|
||||||
const traceIds = await this.discoverTraceSessionIds(projectId, agentId);
|
const traceIds = await this.discoverTraceSessionIds(projectId, agentId);
|
||||||
const rows = new Map(
|
const rows = new Map(
|
||||||
this.deps.sessions.listByAgent(projectId, agentId).map((r) => [r.sessionId, r]),
|
this.deps.sessions.listByAgent(projectId, agentId).map((r) => [r.sessionId, r]),
|
||||||
@@ -89,11 +128,11 @@ export class SessionService {
|
|||||||
if (discovered) rows.set(sessionId, discovered);
|
if (discovered) rows.set(sessionId, discovered);
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...rows.values()]
|
const sorted = [...rows.values()].sort(
|
||||||
.sort(
|
(a, b) => b.createdAt.localeCompare(a.createdAt) || b.sessionId.localeCompare(a.sessionId),
|
||||||
(a, b) => b.createdAt.localeCompare(a.createdAt) || b.sessionId.localeCompare(a.sessionId),
|
);
|
||||||
)
|
const page = paging ? sorted.slice(paging.offset, paging.offset + paging.limit) : sorted;
|
||||||
.map((row) => this.toInfo(row, traceIds.has(row.sessionId)));
|
return Promise.all(page.map((row) => this.toInfo(row, traceIds.has(row.sessionId))));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -196,6 +235,8 @@ export class SessionService {
|
|||||||
modelId,
|
modelId,
|
||||||
provider,
|
provider,
|
||||||
...(args.workspace !== undefined ? { workspaceDir: args.workspace } : {}),
|
...(args.workspace !== undefined ? { workspaceDir: args.workspace } : {}),
|
||||||
|
// The origin is also recorded in core session_meta (Trace), not just the index row.
|
||||||
|
...(args.source !== undefined ? { source: args.source } : {}),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// A missing credential is its own category (the frontend shows localized text
|
// A missing credential is its own category (the frontend shows localized text
|
||||||
@@ -208,6 +249,14 @@ export class SessionService {
|
|||||||
err instanceof Error ? err.message : String(err),
|
err instanceof Error ? err.message : String(err),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// The origin is derived from the just-created core Session's session_meta (the single
|
||||||
|
// source of truth) rather than echoing args.source back: what the registry serves is
|
||||||
|
// exactly what the Trace will record.
|
||||||
|
const metaMsg = session.metaMessage;
|
||||||
|
this.deps.sources.set(
|
||||||
|
session.sessionId,
|
||||||
|
isSessionMeta(metaMsg) ? (asSessionSource(metaMsg.payload.source) ?? null) : null,
|
||||||
|
);
|
||||||
const row: SessionRow = {
|
const row: SessionRow = {
|
||||||
sessionId: session.sessionId,
|
sessionId: session.sessionId,
|
||||||
projectId: args.projectId,
|
projectId: args.projectId,
|
||||||
@@ -218,7 +267,6 @@ export class SessionService {
|
|||||||
approvalMode: args.approvalMode ?? "allow-all",
|
approvalMode: args.approvalMode ?? "allow-all",
|
||||||
title: null,
|
title: null,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
...(args.source !== undefined ? { source: args.source } : {}),
|
|
||||||
};
|
};
|
||||||
this.deps.sessions.insert(row);
|
this.deps.sessions.insert(row);
|
||||||
this.deps.manager.adopt(row, session);
|
this.deps.manager.adopt(row, session);
|
||||||
@@ -238,12 +286,16 @@ export class SessionService {
|
|||||||
return ids;
|
return ids;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Adopts a Session that exists only in the Trace directory: reads session_meta from the first line of the earliest index file. */
|
/**
|
||||||
private async adoptTraceSession(
|
* session_meta from the earliest Trace shard of a Session (the shard whose head carries
|
||||||
|
* the original meta); null when there is no readable Trace or it has no meta. Shared by
|
||||||
|
* adoption backfill and lazy `source` resolution.
|
||||||
|
*/
|
||||||
|
private async readTraceMeta(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
agentId: string,
|
agentId: string,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
): Promise<SessionRow | null> {
|
): Promise<SessionMetaMessage | null> {
|
||||||
const dir = tracesDir(this.deps.root, projectId, agentId);
|
const dir = tracesDir(this.deps.root, projectId, agentId);
|
||||||
let earliest: { path: string; index: number } | null = null;
|
let earliest: { path: string; index: number } | null = null;
|
||||||
for (const dateDir of await listDirsSafe(dir)) {
|
for (const dateDir of await listDirsSafe(dir)) {
|
||||||
@@ -263,13 +315,25 @@ export class SessionService {
|
|||||||
} catch {
|
} catch {
|
||||||
return null; // Corrupt file: skip (does not block the list)
|
return null; // Corrupt file: skip (does not block the list)
|
||||||
}
|
}
|
||||||
const meta = messages.find(isSessionMeta);
|
return messages.find(isSessionMeta) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adopts a Session that exists only in the Trace directory: reads session_meta from the first line of the earliest index file. */
|
||||||
|
private async adoptTraceSession(
|
||||||
|
projectId: string,
|
||||||
|
agentId: string,
|
||||||
|
sessionId: string,
|
||||||
|
): Promise<SessionRow | null> {
|
||||||
|
const meta = await this.readTraceMeta(projectId, agentId, sessionId);
|
||||||
if (!meta) return null;
|
if (!meta) return null;
|
||||||
// An older Trace version's session_meta lacks provider (the model reference
|
// An older Trace version's session_meta lacks provider (the model reference
|
||||||
// wasn't split into separate fields yet): no backward compat, skip adoption
|
// wasn't split into separate fields yet): no backward compat, skip adoption
|
||||||
// (core will give a clear error on resume; the product hasn't launched yet, so
|
// (core will give a clear error on resume; the product hasn't launched yet, so
|
||||||
// old data can simply be deleted and recreated).
|
// old data can simply be deleted and recreated).
|
||||||
if (typeof meta.payload.provider !== "string") return null;
|
if (typeof meta.payload.provider !== "string") return null;
|
||||||
|
// The adoption read already has the meta in hand: record the origin (single source of
|
||||||
|
// truth); on-disk values are narrowed — junk counts as user-created.
|
||||||
|
this.deps.sources.set(sessionId, asSessionSource(meta.payload.source) ?? null);
|
||||||
const row: SessionRow = {
|
const row: SessionRow = {
|
||||||
sessionId,
|
sessionId,
|
||||||
projectId,
|
projectId,
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ describe("scheduler", () => {
|
|||||||
workspace?: string;
|
workspace?: string;
|
||||||
provider?: string;
|
provider?: string;
|
||||||
modelId?: string;
|
modelId?: string;
|
||||||
|
source?: "schedule";
|
||||||
}>;
|
}>;
|
||||||
let events: Array<{ userId: string; event: ScheduleServerEvent }>;
|
let events: Array<{ userId: string; event: ScheduleServerEvent }>;
|
||||||
let errors: ErrorRecordArgs[];
|
let errors: ErrorRecordArgs[];
|
||||||
@@ -288,13 +289,15 @@ describe("scheduler", () => {
|
|||||||
nowMs = T0 + 6 * MIN;
|
nowMs = T0 + 6 * MIN;
|
||||||
await scheduler.tickOnce();
|
await scheduler.tickOnce();
|
||||||
expect(created).toHaveLength(2);
|
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({
|
expect(created[0]).toMatchObject({
|
||||||
projectId: P,
|
projectId: P,
|
||||||
agentId: A,
|
agentId: A,
|
||||||
workspace: "/tmp/ws",
|
workspace: "/tmp/ws",
|
||||||
provider: "custom",
|
provider: "custom",
|
||||||
modelId: "m-bench",
|
modelId: "m-bench",
|
||||||
|
source: "schedule",
|
||||||
});
|
});
|
||||||
expect(started.map((s) => s.sessionId)).toEqual(["session-new-1", "session-new-2"]);
|
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);
|
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 () => {
|
it("half a model reference is 400: the missing half is never inferred", async () => {
|
||||||
await configureModels();
|
await configureModels();
|
||||||
// Only modelId: even though it names the one configured model, the provider is never
|
// 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 { createAgent, saveProjectConfig, sessionMeta, userText } from "@prismshadow/penguin-core";
|
||||||
import type { SessionMetaPayload } from "@prismshadow/penguin-core";
|
import type { SessionMetaPayload } from "@prismshadow/penguin-core";
|
||||||
import { createCoreSessionLoader } from "../src/runtime/session-manager.js";
|
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 type { SessionRow } from "../src/db/repos/sessions.js";
|
||||||
import { HttpError } from "../src/http/errors.js";
|
import { HttpError } from "../src/http/errors.js";
|
||||||
import { makeTempRoot, writeTraceFile } from "./helpers.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).status).toBe(409);
|
||||||
expect((err as HttpError).code).toBe("workspace_missing");
|
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 type { ErrorRecordArgs, ErrorSink } from "../src/runtime/error-recorder.js";
|
||||||
import { SessionManager } from "../src/runtime/session-manager.js";
|
import { SessionManager } from "../src/runtime/session-manager.js";
|
||||||
import type { RuntimeSession, SessionLoader } 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 { TitleRequest } from "../src/runtime/title-generator.js";
|
||||||
import type { UsageContext } from "../src/runtime/usage-recorder.js";
|
import type { UsageContext } from "../src/runtime/usage-recorder.js";
|
||||||
import { waitFor } from "./helpers.js";
|
import { waitFor } from "./helpers.js";
|
||||||
@@ -78,6 +79,7 @@ describe("session-manager", () => {
|
|||||||
let db: DatabaseSync;
|
let db: DatabaseSync;
|
||||||
let sessions: SessionsRepo;
|
let sessions: SessionsRepo;
|
||||||
let channels: ChannelHub;
|
let channels: ChannelHub;
|
||||||
|
let sources: SessionSources;
|
||||||
let recorded: OmniMessage[];
|
let recorded: OmniMessage[];
|
||||||
let recordedCtx: UsageContext[];
|
let recordedCtx: UsageContext[];
|
||||||
|
|
||||||
@@ -85,6 +87,7 @@ describe("session-manager", () => {
|
|||||||
new SessionManager({
|
new SessionManager({
|
||||||
sessions,
|
sessions,
|
||||||
channels,
|
channels,
|
||||||
|
sources,
|
||||||
loader,
|
loader,
|
||||||
recorder: {
|
recorder: {
|
||||||
record: async (ctx, msg) => {
|
record: async (ctx, msg) => {
|
||||||
@@ -114,6 +117,7 @@ describe("session-manager", () => {
|
|||||||
sessions = new SessionsRepo(db);
|
sessions = new SessionsRepo(db);
|
||||||
sessions.insert(ROW);
|
sessions.insert(ROW);
|
||||||
channels = new ChannelHub();
|
channels = new ChannelHub();
|
||||||
|
sources = new SessionSources();
|
||||||
recorded = [];
|
recorded = [];
|
||||||
recordedCtx = [];
|
recordedCtx = [];
|
||||||
});
|
});
|
||||||
@@ -287,6 +291,7 @@ describe("session-manager", () => {
|
|||||||
const manager = new SessionManager({
|
const manager = new SessionManager({
|
||||||
sessions,
|
sessions,
|
||||||
channels,
|
channels,
|
||||||
|
sources,
|
||||||
loader: loaderOf(fake),
|
loader: loaderOf(fake),
|
||||||
recorder: { record: async () => {} },
|
recorder: { record: async () => {} },
|
||||||
titles: {
|
titles: {
|
||||||
@@ -301,6 +306,11 @@ describe("session-manager", () => {
|
|||||||
expect(child?.agentId).toBe("child_agent");
|
expect(child?.agentId).toBe("child_agent");
|
||||||
expect(child?.modelId).toBe("m-child");
|
expect(child?.modelId).toBe("m-child");
|
||||||
expect(child?.workspace).toBe("/tmp/w-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).
|
// 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();
|
expect(child?.title).toBeNull();
|
||||||
|
|
||||||
@@ -577,6 +587,7 @@ describe("session-manager", () => {
|
|||||||
const manager = new SessionManager({
|
const manager = new SessionManager({
|
||||||
sessions,
|
sessions,
|
||||||
channels,
|
channels,
|
||||||
|
sources,
|
||||||
loader: loaderOf(plainSession),
|
loader: loaderOf(plainSession),
|
||||||
recorder: { record: async () => {} },
|
recorder: { record: async () => {} },
|
||||||
titles: {
|
titles: {
|
||||||
@@ -629,6 +640,7 @@ describe("session-manager", () => {
|
|||||||
const manager = new SessionManager({
|
const manager = new SessionManager({
|
||||||
sessions,
|
sessions,
|
||||||
channels,
|
channels,
|
||||||
|
sources,
|
||||||
loader: loaderOf(longSession),
|
loader: loaderOf(longSession),
|
||||||
recorder: { record: async () => {} },
|
recorder: { record: async () => {} },
|
||||||
titles: {
|
titles: {
|
||||||
@@ -695,6 +707,7 @@ describe("session-manager", () => {
|
|||||||
const manager = new SessionManager({
|
const manager = new SessionManager({
|
||||||
sessions,
|
sessions,
|
||||||
channels,
|
channels,
|
||||||
|
sources,
|
||||||
loader: loaderOf(delegating),
|
loader: loaderOf(delegating),
|
||||||
recorder: {
|
recorder: {
|
||||||
record: async (_ctx, msg) => {
|
record: async (_ctx, msg) => {
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* Sidebar session paging: each group displays at most SIDEBAR_PAGE_SIZE (20) rows and the
|
||||||
|
* store fetches per Agent with limit+1 pages — 21 seeded sessions must load as one page of
|
||||||
|
* 20 plus a "更多" row; clicking it reveals the loaded rows AND fetches the next server
|
||||||
|
* page, after which all 21 rows are visible and the "更多" row disappears (no more hidden
|
||||||
|
* rows, no more server pages).
|
||||||
|
*
|
||||||
|
* Standalone spec: shares one server with the other specs, so it registers its own user
|
||||||
|
* (auto-provisions a default Project) and seeds sessions via the API.
|
||||||
|
*/
|
||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { provisionAndLogin } from "./auth.mjs";
|
||||||
|
|
||||||
|
const BASE = process.env.BASE_URL;
|
||||||
|
const MOCK = process.env.MOCK_URL;
|
||||||
|
const U = "pageuser";
|
||||||
|
const P = "password123";
|
||||||
|
const TOTAL = 21; // one past the 20-row page
|
||||||
|
|
||||||
|
test("sidebar shows 20 sessions plus a More row; More loads the 21st and then disappears", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await provisionAndLogin(page.request, U, P);
|
||||||
|
const projects = await (await page.request.get(`${BASE}/api/projects`)).json();
|
||||||
|
const projectId = projects.projects[0].projectId;
|
||||||
|
|
||||||
|
const put = await page.request.put(`${BASE}/api/projects/${projectId}/models`, {
|
||||||
|
data: {
|
||||||
|
defaultModel: { provider: "custom", modelId: "claude-4-8" },
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
provider: "custom",
|
||||||
|
modelId: "claude-4-8",
|
||||||
|
apiKey: "sk-mock",
|
||||||
|
baseUrl: MOCK,
|
||||||
|
contextWindow: 200000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(put.ok(), "put models").toBeTruthy();
|
||||||
|
|
||||||
|
// Seed 21 sessions (each gets its own auto temp Workspace; the sidebar merges them into
|
||||||
|
// the single temp-workspace group, so the display cap applies to one group).
|
||||||
|
for (let i = 0; i < TOTAL; i++) {
|
||||||
|
const res = await page.request.post(
|
||||||
|
`${BASE}/api/projects/${projectId}/agents/default_agent/sessions`,
|
||||||
|
{ data: {} },
|
||||||
|
);
|
||||||
|
expect(res.ok(), `create session ${i}: ${await res.text()}`).toBeTruthy();
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.goto(`${BASE}/chat`);
|
||||||
|
const sidebar = page.getByRole("complementary");
|
||||||
|
// Untitled rows all read "新对话" (distinct from the nav's "新建对话", which is not matched
|
||||||
|
// by substring). One page: exactly 20 rows.
|
||||||
|
const rows = sidebar.getByText("新对话");
|
||||||
|
await expect(rows).toHaveCount(20);
|
||||||
|
const more = sidebar.getByRole("button", { name: "更多" });
|
||||||
|
await expect(more).toBeVisible();
|
||||||
|
|
||||||
|
// More: raises the display cap and fetches the next server page → all 21 rows, no More left.
|
||||||
|
await more.click();
|
||||||
|
await expect(rows).toHaveCount(TOTAL);
|
||||||
|
await expect(more).toHaveCount(0);
|
||||||
|
});
|
||||||
@@ -105,11 +105,39 @@ test("subagent card survives a reload; child session title is generated from its
|
|||||||
await openSubagent();
|
await openSubagent();
|
||||||
|
|
||||||
// --- Child session title: generated by the model from the child session's own conversation (async, poll until persisted) ---
|
// --- Child session title: generated by the model from the child session's own conversation (async, poll until persisted) ---
|
||||||
const childTitle = async () => {
|
const childOf = async () => {
|
||||||
const list = await (
|
const list = await (
|
||||||
await page.request.get(`${BASE}/api/projects/${projectId}/agents/${agentId}/sessions`)
|
await page.request.get(`${BASE}/api/projects/${projectId}/agents/${agentId}/sessions`)
|
||||||
).json();
|
).json();
|
||||||
return list.sessions.find((s) => s.sessionId !== sessionId)?.title ?? null;
|
return list.sessions.find((s) => s.sessionId !== sessionId) ?? null;
|
||||||
};
|
};
|
||||||
await expect.poll(childTitle, { timeout: 10000 }).toBe("Subagent TODO summary");
|
await expect
|
||||||
|
.poll(async () => (await childOf())?.title ?? null, { timeout: 10000 })
|
||||||
|
.toBe("Subagent TODO summary");
|
||||||
|
const child = await childOf();
|
||||||
|
|
||||||
|
// --- The child's OWN Trace session_meta records source=subagent: written by core's spawn
|
||||||
|
// site, the single source of truth (the server's registration fallback cannot mask this —
|
||||||
|
// it never writes the child Trace), and what the derived list source ultimately rests on. ---
|
||||||
|
const childMessages = await (
|
||||||
|
await page.request.get(`${BASE}/api/sessions/${child.sessionId}/messages`)
|
||||||
|
).json();
|
||||||
|
const childMeta = childMessages.messages.find(
|
||||||
|
(m) => m.type === "session_meta" && !m.origin?.length,
|
||||||
|
);
|
||||||
|
expect(childMeta, "child trace session_meta").toBeTruthy();
|
||||||
|
expect(childMeta.payload.source).toBe("subagent");
|
||||||
|
|
||||||
|
// --- Sidebar: the child session (source=subagent) nests inside the collapsed "Subagents"
|
||||||
|
// folder (per-origin folders sit parallel to "Archived" within the same temp-workspace
|
||||||
|
// group). Reload first so the sidebar list carries the persisted title/source, and the
|
||||||
|
// folder is back to its default collapsed state. ---
|
||||||
|
await page.reload();
|
||||||
|
const sidebar = page.getByRole("complementary");
|
||||||
|
const subagentFolder = sidebar.getByRole("button", { name: "子智能体(1)" });
|
||||||
|
await expect(subagentFolder, "collapsed Subagents folder").toBeVisible();
|
||||||
|
// Collapsed by default: the child row is not rendered until the folder is expanded.
|
||||||
|
await expect(sidebar.getByText("Subagent TODO summary")).toHaveCount(0);
|
||||||
|
await subagentFolder.click();
|
||||||
|
await expect(sidebar.getByText("Subagent TODO summary")).toBeVisible();
|
||||||
});
|
});
|
||||||
@@ -185,9 +185,16 @@ export const getAgentTraces = (projectId: string, agentId: string) =>
|
|||||||
|
|
||||||
// Session ---------------------------------------------------------------------
|
// Session ---------------------------------------------------------------------
|
||||||
|
|
||||||
export const listSessions = (projectId: string, agentId: string) =>
|
/** Optional paging (both absent = full list): the store requests `limit+1` per page to detect "has more". */
|
||||||
|
export const listSessions = (
|
||||||
|
projectId: string,
|
||||||
|
agentId: string,
|
||||||
|
paging?: { offset: number; limit: number },
|
||||||
|
) =>
|
||||||
apiFetch<SessionsResponse>(
|
apiFetch<SessionsResponse>(
|
||||||
`/api/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}/sessions`,
|
`/api/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}/sessions${
|
||||||
|
paging ? `?limit=${paging.limit}&offset=${paging.offset}` : ""
|
||||||
|
}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Server directory browsing: `path` is an absolute path; empty means start from the server's home directory. */
|
/** Server directory browsing: `path` is an absolute path; empty means start from the server's home directory. */
|
||||||
|
|||||||
@@ -15,10 +15,10 @@
|
|||||||
* there's no longer a separate "quick / advanced" pair of new-chat dialogs.
|
* there's no longer a separate "quick / advanced" pair of new-chat dialogs.
|
||||||
* Color scheme is white/gray-based: active state uses a solid gray fill, running status uses a small color dot, no large blocks of color.
|
* Color scheme is white/gray-based: active state uses a solid gray fill, running status uses a small color dot, no large blocks of color.
|
||||||
*/
|
*/
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { NavLink, useMatch, useNavigate } from "react-router";
|
import { NavLink, useMatch, useNavigate } from "react-router";
|
||||||
import type { SessionInfo } from "@prismshadow/penguin-server/api";
|
import type { SessionInfo, SessionSource } from "@prismshadow/penguin-server/api";
|
||||||
import * as api from "../../api/endpoints";
|
import * as api from "../../api/endpoints";
|
||||||
import { ApiError } from "../../api/client";
|
import { ApiError } from "../../api/client";
|
||||||
import { S } from "../../lib/strings";
|
import { S } from "../../lib/strings";
|
||||||
@@ -30,10 +30,14 @@ import type { Accent, Currency, FontScale, ThemeMode } from "../../state/theme";
|
|||||||
import { agentDisplayName, projectDisplayName, useProject } from "../../state/project";
|
import { agentDisplayName, projectDisplayName, useProject } from "../../state/project";
|
||||||
import { useSessions } from "../../state/sessions";
|
import { useSessions } from "../../state/sessions";
|
||||||
import {
|
import {
|
||||||
|
SIDEBAR_PAGE_SIZE,
|
||||||
|
groupAgentsWithMore,
|
||||||
groupSessionsByWorkspace,
|
groupSessionsByWorkspace,
|
||||||
|
partitionSessions,
|
||||||
pinnedFirst,
|
pinnedFirst,
|
||||||
workspaceGroupKey,
|
workspaceGroupKey,
|
||||||
} from "../../lib/session-grouping";
|
} from "../../lib/session-grouping";
|
||||||
|
import type { SessionPartition } from "../../lib/session-grouping";
|
||||||
import { Dropdown } from "../ui/dropdown";
|
import { Dropdown } from "../ui/dropdown";
|
||||||
import { AgentAvatar } from "../ui/agent-avatar";
|
import { AgentAvatar } from "../ui/agent-avatar";
|
||||||
import { Chevron } from "../ui/chevron";
|
import { Chevron } from "../ui/chevron";
|
||||||
@@ -147,6 +151,13 @@ function saveGroupSet(storageKey: string | null, next: ReadonlySet<string>): voi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open-state key of a per-origin folder (subagent / scheduled) inside a group: each folder
|
||||||
|
* has its own state. "\0" never appears in Agent ids or Workspace paths, so the composite
|
||||||
|
* never collides across groups or with plain group keys.
|
||||||
|
*/
|
||||||
|
const sourceFolderKey = (groupKey: string, source: SessionSource) => `${source}\0${groupKey}`;
|
||||||
|
|
||||||
/** Session status dot: running pulses green, compacting shows an amber dot; idle shows nothing. */
|
/** Session status dot: running pulses green, compacting shows an amber dot; idle shows nothing. */
|
||||||
function StatusDot({ session }: { session: SessionInfo }) {
|
function StatusDot({ session }: { session: SessionInfo }) {
|
||||||
if (session.status === "running") {
|
if (session.status === "running") {
|
||||||
@@ -189,7 +200,8 @@ export function Sidebar({
|
|||||||
currentAgent,
|
currentAgent,
|
||||||
setCurrentAgentId,
|
setCurrentAgentId,
|
||||||
} = useProject();
|
} = useProject();
|
||||||
const { sessions, byAgent, loading, remove, replace } = useSessions();
|
const { sessions, byAgent, hasMoreByAgent, loadMoreFor, loading, remove, replace } =
|
||||||
|
useSessions();
|
||||||
const chatMatch = useMatch("/chat/:sessionId");
|
const chatMatch = useMatch("/chat/:sessionId");
|
||||||
const activeSessionId = chatMatch?.params.sessionId ?? null;
|
const activeSessionId = chatMatch?.params.sessionId ?? null;
|
||||||
|
|
||||||
@@ -218,6 +230,10 @@ export function Sidebar({
|
|||||||
}, [collapseStoreKey, pinStoreKey]);
|
}, [collapseStoreKey, pinStoreKey]);
|
||||||
/** Expanded "archived" groups (collapsed by default), keyed like collapsedGroups. */
|
/** Expanded "archived" groups (collapsed by default), keyed like collapsedGroups. */
|
||||||
const [openArchived, setOpenArchived] = useState<ReadonlySet<string>>(new Set());
|
const [openArchived, setOpenArchived] = useState<ReadonlySet<string>>(new Set());
|
||||||
|
/** Expanded per-origin folders (subagent / scheduled Sessions; collapsed by default), keyed by sourceFolderKey — each folder has its own open state. */
|
||||||
|
const [openSourceFolders, setOpenSourceFolders] = useState<ReadonlySet<string>>(new Set());
|
||||||
|
/** Per-group display cap for active rows (keyed by group key; absent = SIDEBAR_PAGE_SIZE). "More" raises it a page at a time. */
|
||||||
|
const [groupCaps, setGroupCaps] = useState<ReadonlyMap<string, number>>(new Map());
|
||||||
/** Session pending delete confirmation (null = none). */
|
/** Session pending delete confirmation (null = none). */
|
||||||
const [deletingSession, setDeletingSession] = useState<SessionInfo | null>(null);
|
const [deletingSession, setDeletingSession] = useState<SessionInfo | null>(null);
|
||||||
const [deletingBusy, setDeletingBusy] = useState(false);
|
const [deletingBusy, setDeletingBusy] = useState(false);
|
||||||
@@ -278,6 +294,36 @@ export function Sidebar({
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const toggleSourceFolder = (groupKey: string, source: SessionSource) =>
|
||||||
|
setOpenSourceFolders((prev) => {
|
||||||
|
const key = sourceFolderKey(groupKey, source);
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(key)) next.delete(key);
|
||||||
|
else next.add(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
// The open chat is an automation-created Session: expand exactly its origin's folder in its
|
||||||
|
// group, so the active row is never hidden inside a collapsed folder (mirrors the archived
|
||||||
|
// expansion on archiving the open chat; archived wins, so an archived Session is left to
|
||||||
|
// that folder). Auto-expansion fires ONCE per (grouping mode, active session): the ref guard
|
||||||
|
// keeps list mutations (status ticks, reloads) from re-opening a folder the user explicitly
|
||||||
|
// collapsed while that chat stays open. `sessions` must remain a dependency — the active
|
||||||
|
// session may not be in the list yet on first render, and the guard is only set once the
|
||||||
|
// row is actually found and expanded.
|
||||||
|
const lastAutoExpandedRef = useRef<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeSessionId) return;
|
||||||
|
const s = sessions.find((x) => x.sessionId === activeSessionId);
|
||||||
|
if (!s || !s.source || s.archived) return;
|
||||||
|
const guard = `${groupMode}\0${activeSessionId}`;
|
||||||
|
if (lastAutoExpandedRef.current === guard) return;
|
||||||
|
lastAutoExpandedRef.current = guard;
|
||||||
|
const groupKey = groupMode === "agent" ? s.agentId : workspaceGroupKey(s.workspace);
|
||||||
|
const key = sourceFolderKey(groupKey, s.source);
|
||||||
|
setOpenSourceFolders((prev) => (prev.has(key) ? prev : new Set(prev).add(key)));
|
||||||
|
}, [activeSessionId, sessions, groupMode]);
|
||||||
|
|
||||||
/** Archive / unarchive: persists immediately and updates in place (fails silently; the next list refresh self-corrects). */
|
/** Archive / unarchive: persists immediately and updates in place (fails silently; the next list refresh self-corrects). */
|
||||||
const toggleArchive = async (s: SessionInfo) => {
|
const toggleArchive = async (s: SessionInfo) => {
|
||||||
// Archiving the currently open chat: expand the "archived" group so it doesn't silently vanish from the sidebar with no way back.
|
// Archiving the currently open chat: expand the "archived" group so it doesn't silently vanish from the sidebar with no way back.
|
||||||
@@ -404,36 +450,108 @@ export function Sidebar({
|
|||||||
</ul>
|
</ul>
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Expanded group body shared by both modes: active rows + the collapsed-by-default archived subgroup (keyed by the group key). */
|
const folderClass =
|
||||||
const renderGroupBody = (
|
"flex w-full items-center gap-1 rounded px-1.5 py-1 text-left text-[11px] font-medium text-gray-400 transition-colors duration-150 hover:bg-gray-200/50 dark:text-gray-500 dark:hover:bg-gray-800/50";
|
||||||
|
|
||||||
|
/** Collapsed-by-default per-origin folder (subagent / scheduled), parallel to the archived folder. */
|
||||||
|
const renderSourceFolder = (
|
||||||
groupKey: string,
|
groupKey: string,
|
||||||
activeList: SessionInfo[],
|
source: SessionSource,
|
||||||
archivedList: SessionInfo[],
|
rows: SessionInfo[],
|
||||||
withAgentHint: boolean,
|
withAgentHint: boolean,
|
||||||
|
) => {
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
const open = openSourceFolders.has(sourceFolderKey(groupKey, source));
|
||||||
|
return (
|
||||||
|
<div className="mt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleSourceFolder(groupKey, source)}
|
||||||
|
className={folderClass}
|
||||||
|
>
|
||||||
|
<Chevron open={open} size={12} />
|
||||||
|
{S.chat.sourceGroups[source](rows.length)}
|
||||||
|
</button>
|
||||||
|
{open && renderRows(rows, withAgentHint)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** "More": reveal one more page of already-loaded active rows AND fetch the next server page for every contributing Agent that still has one. */
|
||||||
|
const showMore = (groupKey: string, moreAgents: string[]) => {
|
||||||
|
setGroupCaps((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(groupKey, (prev.get(groupKey) ?? SIDEBAR_PAGE_SIZE) + SIDEBAR_PAGE_SIZE);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
if (moreAgents.length > 0) void loadMoreFor(moreAgents);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expanded group body shared by both modes: user rows (display-capped; "More" reveals and
|
||||||
|
* loads further pages) + the collapsed-by-default subagent / scheduled / archived subgroups
|
||||||
|
* (keyed by the group key; rendered uncapped over loaded data — they are collapsed by
|
||||||
|
* default and only ever hold what the pages brought in).
|
||||||
|
*/
|
||||||
|
const renderGroupBody = (
|
||||||
|
groupKey: string,
|
||||||
|
parts: SessionPartition,
|
||||||
|
withAgentHint: boolean,
|
||||||
|
/** Agents contributing to this group that still have unfetched server pages. */
|
||||||
|
moreAgents: string[],
|
||||||
) => {
|
) => {
|
||||||
const archivedOpen = openArchived.has(groupKey);
|
const archivedOpen = openArchived.has(groupKey);
|
||||||
|
const cap = groupCaps.get(groupKey) ?? SIDEBAR_PAGE_SIZE;
|
||||||
|
const shownActive = parts.active.slice(0, cap);
|
||||||
|
// "More" while hidden loaded rows exist OR any contributing Agent has server-side pages
|
||||||
|
// left; a fetched page can also land rows in the folders below, so one click may grow
|
||||||
|
// the visible list by fewer than a full page — the row simply stays until exhausted.
|
||||||
|
const hasMore = parts.active.length > cap || moreAgents.length > 0;
|
||||||
|
const empty =
|
||||||
|
parts.active.length === 0 &&
|
||||||
|
parts.subagent.length === 0 &&
|
||||||
|
parts.schedule.length === 0 &&
|
||||||
|
parts.archived.length === 0;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{activeList.length === 0 && archivedList.length === 0 ? (
|
{empty ? (
|
||||||
<p className="px-2.5 py-1 text-xs text-gray-400 dark:text-gray-600">
|
<p className="px-2.5 py-1 text-xs text-gray-400 dark:text-gray-600">
|
||||||
{S.chat.noSessions}
|
{S.chat.noSessions}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
renderRows(activeList, withAgentHint)
|
renderRows(shownActive, withAgentHint)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Archived group (collapsed by default) */}
|
{/* Load/reveal more (kept adjacent to the active list it extends, above the folders) */}
|
||||||
{archivedList.length > 0 && (
|
{hasMore && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={S.chat.loadMore}
|
||||||
|
onClick={() => showMore(groupKey, moreAgents)}
|
||||||
|
className={`${folderClass} mt-0.5`}
|
||||||
|
>
|
||||||
|
<span className="w-3" aria-hidden />
|
||||||
|
{S.chat.loadMore}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Per-origin folders (collapsed by default, above Archived): subagent first — spawned
|
||||||
|
from the conversations at hand — then scheduled background runs. */}
|
||||||
|
{renderSourceFolder(groupKey, "subagent", parts.subagent, withAgentHint)}
|
||||||
|
{renderSourceFolder(groupKey, "schedule", parts.schedule, withAgentHint)}
|
||||||
|
|
||||||
|
{/* Archived group (collapsed by default; archived wins over the per-origin folders) */}
|
||||||
|
{parts.archived.length > 0 && (
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggleArchivedGroup(groupKey)}
|
onClick={() => toggleArchivedGroup(groupKey)}
|
||||||
className="flex w-full items-center gap-1 rounded px-1.5 py-1 text-left text-[11px] font-medium text-gray-400 transition-colors duration-150 hover:bg-gray-200/50 dark:text-gray-500 dark:hover:bg-gray-800/50"
|
className={folderClass}
|
||||||
>
|
>
|
||||||
<Chevron open={archivedOpen} size={12} />
|
<Chevron open={archivedOpen} size={12} />
|
||||||
{S.chat.archivedGroup(archivedList.length)}
|
{S.chat.archivedGroup(parts.archived.length)}
|
||||||
</button>
|
</button>
|
||||||
{archivedOpen && renderRows(archivedList, withAgentHint)}
|
{archivedOpen && renderRows(parts.archived, withAgentHint)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -624,9 +742,7 @@ export function Sidebar({
|
|||||||
<SkeletonList rows={5} />
|
<SkeletonList rows={5} />
|
||||||
) : (
|
) : (
|
||||||
orderedAgents.map((agent) => {
|
orderedAgents.map((agent) => {
|
||||||
const list = byAgent.get(agent.agentId) ?? [];
|
const parts = partitionSessions(byAgent.get(agent.agentId) ?? []);
|
||||||
const activeList = list.filter((s) => !s.archived);
|
|
||||||
const archivedList = list.filter((s) => s.archived);
|
|
||||||
const collapsed = collapsedGroups.has(agent.agentId);
|
const collapsed = collapsedGroups.has(agent.agentId);
|
||||||
const pinned = pinnedGroups.has(agent.agentId);
|
const pinned = pinnedGroups.has(agent.agentId);
|
||||||
return (
|
return (
|
||||||
@@ -678,7 +794,12 @@ export function Sidebar({
|
|||||||
|
|
||||||
{collapsed
|
{collapsed
|
||||||
? null
|
? null
|
||||||
: renderGroupBody(agent.agentId, activeList, archivedList, false)}
|
: renderGroupBody(
|
||||||
|
agent.agentId,
|
||||||
|
parts,
|
||||||
|
false,
|
||||||
|
hasMoreByAgent.get(agent.agentId) === true ? [agent.agentId] : [],
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
@@ -691,8 +812,7 @@ export function Sidebar({
|
|||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
orderedWorkspaceGroups.map((group) => {
|
orderedWorkspaceGroups.map((group) => {
|
||||||
const activeList = group.sessions.filter((s) => !s.archived);
|
const parts = partitionSessions(group.sessions);
|
||||||
const archivedList = group.sessions.filter((s) => s.archived);
|
|
||||||
const collapsed = collapsedGroups.has(group.key);
|
const collapsed = collapsedGroups.has(group.key);
|
||||||
const pinned = pinnedGroups.has(group.key);
|
const pinned = pinnedGroups.has(group.key);
|
||||||
return (
|
return (
|
||||||
@@ -717,8 +837,9 @@ export function Sidebar({
|
|||||||
<span className="min-w-0 truncate text-xs font-semibold text-gray-500 dark:text-gray-400">
|
<span className="min-w-0 truncate text-xs font-semibold text-gray-500 dark:text-gray-400">
|
||||||
{group.temp ? S.chat.tempWorkspaces : group.label}
|
{group.temp ? S.chat.tempWorkspaces : group.label}
|
||||||
</span>
|
</span>
|
||||||
|
{/* Header count = non-archived Sessions (user + automation-created; unchanged semantics) */}
|
||||||
<span className="shrink-0 text-[11px] text-gray-400 dark:text-gray-500">
|
<span className="shrink-0 text-[11px] text-gray-400 dark:text-gray-500">
|
||||||
{activeList.length}
|
{parts.active.length + parts.subagent.length + parts.schedule.length}
|
||||||
</span>
|
</span>
|
||||||
<Chevron open={!collapsed} size={12} className="text-gray-400" />
|
<Chevron open={!collapsed} size={12} className="text-gray-400" />
|
||||||
<span className="min-w-0 flex-1" />
|
<span className="min-w-0 flex-1" />
|
||||||
@@ -736,7 +857,15 @@ export function Sidebar({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{collapsed ? null : renderGroupBody(group.key, activeList, archivedList, true)}
|
{/* A workspace group can span Agents: "More" fans out to every contributing Agent that still has pages. */}
|
||||||
|
{collapsed
|
||||||
|
? null
|
||||||
|
: renderGroupBody(
|
||||||
|
group.key,
|
||||||
|
parts,
|
||||||
|
true,
|
||||||
|
groupAgentsWithMore(group.sessions, hasMoreByAgent),
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -182,13 +182,36 @@ export function ChatPage() {
|
|||||||
};
|
};
|
||||||
}, [projectId, selectedAgentId]);
|
}, [projectId, selectedAgentId]);
|
||||||
|
|
||||||
|
// The Session list is paged: a deep-linked Session (old bookmark, cross-page jump) may sit
|
||||||
|
// beyond the loaded pages. Look it up directly and insert it before the auto-select effect
|
||||||
|
// below concludes it doesn't exist; only a failed probe releases that redirect.
|
||||||
|
const [probeFailedId, setProbeFailedId] = useState<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (draft || !routeSessionId || sessionsLoading) return;
|
||||||
|
if (sessions.some((s) => s.sessionId === routeSessionId)) return;
|
||||||
|
let cancelled = false;
|
||||||
|
api.getSession(routeSessionId).then(
|
||||||
|
(res) => {
|
||||||
|
if (!cancelled) addSession(res.session);
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
if (!cancelled) setProbeFailedId(routeSessionId);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [draft, routeSessionId, sessionsLoading, sessions, addSession]);
|
||||||
|
|
||||||
// Auto-select the most recent Session when the route doesn't select one; if there are none at all, fall back to draft state (instead of auto-creating one).
|
// Auto-select the most recent Session when the route doesn't select one; if there are none at all, fall back to draft state (instead of auto-creating one).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sessionsLoading || draft) return;
|
if (sessionsLoading || draft) return;
|
||||||
if (routeSessionId && sessions.some((s) => s.sessionId === routeSessionId)) return;
|
if (routeSessionId && sessions.some((s) => s.sessionId === routeSessionId)) return;
|
||||||
|
// A routed id missing from the paged list isn't gone until the direct lookup fails.
|
||||||
|
if (routeSessionId && probeFailedId !== routeSessionId) return;
|
||||||
const first = sessions[0];
|
const first = sessions[0];
|
||||||
navigate(first ? `/chat/${first.sessionId}` : `/chat/${DRAFT_SESSION_ID}`, { replace: true });
|
navigate(first ? `/chat/${first.sessionId}` : `/chat/${DRAFT_SESSION_ID}`, { replace: true });
|
||||||
}, [sessionsLoading, draft, routeSessionId, sessions, navigate]);
|
}, [sessionsLoading, draft, routeSessionId, probeFailedId, sessions, navigate]);
|
||||||
|
|
||||||
// Sync task_state to the sidebar list badge.
|
// Sync task_state to the sidebar list badge.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -158,6 +158,8 @@ const summaryClass =
|
|||||||
function SessionMetaBody({ p }: { p: Record<string, unknown> }) {
|
function SessionMetaBody({ p }: { p: Record<string, unknown> }) {
|
||||||
const rows: Array<[string, string]> = [
|
const rows: Array<[string, string]> = [
|
||||||
["session_id", String(p.session_id ?? "")],
|
["session_id", String(p.session_id ?? "")],
|
||||||
|
// Session origin (subagent / schedule); user-created sessions have no source and show the empty dash.
|
||||||
|
["source", String(p.source ?? "")],
|
||||||
["model_id", String(p.model_id ?? "")],
|
["model_id", String(p.model_id ?? "")],
|
||||||
["context_window", String(p.model_context_window ?? "")],
|
["context_window", String(p.model_context_window ?? "")],
|
||||||
["thinking_level", String(p.thinking_level ?? "")],
|
["thinking_level", String(p.thinking_level ?? "")],
|
||||||
|
|||||||
@@ -41,6 +41,72 @@ export function workspaceLabel(workspace: string): string {
|
|||||||
return parts[parts.length - 1] ?? "/";
|
return parts[parts.length - 1] ?? "/";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sidebar page size: sessions fetched per Agent per page, and the per-group display cap
|
||||||
|
* step for active rows. Fetches use limit = SIDEBAR_PAGE_SIZE + 1 (see splitPage) so one
|
||||||
|
* request both fills a page and answers "is there more" without a response-envelope change.
|
||||||
|
*/
|
||||||
|
export const SIDEBAR_PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies the limit+1 fetch trick: `fetched` came from a request with `limit = pageSize + 1`;
|
||||||
|
* the visible page is the first `pageSize` items, and an overflow item (never shown) proves
|
||||||
|
* the server has more.
|
||||||
|
*/
|
||||||
|
export function splitPage<T>(fetched: T[], pageSize: number): { items: T[]; hasMore: boolean } {
|
||||||
|
return fetched.length > pageSize
|
||||||
|
? { items: fetched.slice(0, pageSize), hasMore: true }
|
||||||
|
: { items: fetched, hasMore: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The distinct Agents contributing to a group that still have unfetched server pages —
|
||||||
|
* a workspace-mode group can span Agents, so "load more" for the group fans out to every
|
||||||
|
* contributing Agent with more (agent-mode groups are single-Agent and get 0..1 entries).
|
||||||
|
*/
|
||||||
|
export function groupAgentsWithMore(
|
||||||
|
sessions: SessionInfo[],
|
||||||
|
hasMoreByAgent: ReadonlyMap<string, boolean>,
|
||||||
|
): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const s of sessions) {
|
||||||
|
if (hasMoreByAgent.get(s.agentId) === true && !out.includes(s.agentId)) out.push(s.agentId);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Four-way split of one sidebar group's Sessions (rendered top to bottom in this order). */
|
||||||
|
export interface SessionPartition {
|
||||||
|
/** User-created, not archived: rendered directly in the group body. */
|
||||||
|
active: SessionInfo[];
|
||||||
|
/** Subagent-created (`source === "subagent"`), not archived: the collapsed "Subagents" folder. */
|
||||||
|
subagent: SessionInfo[];
|
||||||
|
/** Schedule-created (`source === "schedule"`), not archived: the collapsed "Scheduled" folder. */
|
||||||
|
schedule: SessionInfo[];
|
||||||
|
/** Archived: the collapsed "Archived" folder. Archived wins — an archived Session with a `source` goes here only. */
|
||||||
|
archived: SessionInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Partitions a group's Sessions for rendering. Classification precedence: archived wins
|
||||||
|
* regardless of `source` (archiving is an explicit user action, so the Archived folder
|
||||||
|
* must show everything the user put there); otherwise a Session goes to its origin's
|
||||||
|
* bucket, and an unrecognized future source falls through to the user rows (visible,
|
||||||
|
* with its badge) rather than vanishing into the wrong folder. The sidebar renders the
|
||||||
|
* parts top to bottom in the interface's field order — user rows, Subagents folder,
|
||||||
|
* Scheduled folder, Archived folder. Input order is preserved within each part.
|
||||||
|
*/
|
||||||
|
export function partitionSessions(sessions: SessionInfo[]): SessionPartition {
|
||||||
|
const parts: SessionPartition = { active: [], subagent: [], schedule: [], archived: [] };
|
||||||
|
for (const s of sessions) {
|
||||||
|
if (s.archived) parts.archived.push(s);
|
||||||
|
else if (s.source === "subagent") parts.subagent.push(s);
|
||||||
|
else if (s.source === "schedule") parts.schedule.push(s);
|
||||||
|
else parts.active.push(s);
|
||||||
|
}
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
export interface WorkspaceGroup {
|
export interface WorkspaceGroup {
|
||||||
/** Stable group key: the Workspace path, or TEMP_WORKSPACE_GROUP_KEY for the merged temp group. */
|
/** Stable group key: the Workspace path, or TEMP_WORKSPACE_GROUP_KEY for the merged temp group. */
|
||||||
key: string;
|
key: string;
|
||||||
|
|||||||
@@ -700,6 +700,13 @@ When done, open index.html in a browser and self-test once.`,
|
|||||||
archiveSession: "Archive",
|
archiveSession: "Archive",
|
||||||
unarchiveSession: "Unarchive",
|
unarchiveSession: "Unarchive",
|
||||||
archivedGroup: (n: number) => `Archived (${n})`,
|
archivedGroup: (n: number) => `Archived (${n})`,
|
||||||
|
/** Sidebar group "reveal/load next page" row (display cap + server paging). */
|
||||||
|
loadMore: "More",
|
||||||
|
/** Sidebar folders for automation-created sessions (one per origin), parallel to Archived; wording matches the sourceNames badges. */
|
||||||
|
sourceGroups: {
|
||||||
|
subagent: (n: number) => `Subagents (${n})`,
|
||||||
|
schedule: (n: number) => `Scheduled (${n})`,
|
||||||
|
},
|
||||||
skillsBanner: (names: string[]): string =>
|
skillsBanner: (names: string[]): string =>
|
||||||
`Using skill${names.length === 1 ? "" : "s"}: ${names.join(", ")}`,
|
`Using skill${names.length === 1 ? "" : "s"}: ${names.join(", ")}`,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -680,6 +680,13 @@ Penguin 视觉风格(见 web-design 技能),深色/浅色主题(<html da
|
|||||||
archiveSession: "归档",
|
archiveSession: "归档",
|
||||||
unarchiveSession: "取消归档",
|
unarchiveSession: "取消归档",
|
||||||
archivedGroup: (n: number) => `已归档(${n})`,
|
archivedGroup: (n: number) => `已归档(${n})`,
|
||||||
|
/** Sidebar group "reveal/load next page" row (display cap + server paging). */
|
||||||
|
loadMore: "更多",
|
||||||
|
/** Sidebar folders for automation-created sessions (one per origin), parallel to 已归档; wording matches the sourceNames badges. */
|
||||||
|
sourceGroups: {
|
||||||
|
subagent: (n: number) => `子智能体(${n})`,
|
||||||
|
schedule: (n: number) => `定时任务(${n})`,
|
||||||
|
},
|
||||||
skillsBanner: (names: string[]): string => `使用技能:${names.join("、")}`,
|
skillsBanner: (names: string[]): string => `使用技能:${names.join("、")}`,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,12 @@
|
|||||||
* the sidebar groups by Agent, so all Agents' Sessions are loaded at once (fetched in parallel);
|
* the sidebar groups by Agent, so all Agents' Sessions are loaded at once (fetched in parallel);
|
||||||
* the chat page shares this same data for status sync / title events / self-healing reload.
|
* the chat page shares this same data for status sync / title events / self-healing reload.
|
||||||
*
|
*
|
||||||
|
* **Paged**: each Agent fetches SIDEBAR_PAGE_SIZE sessions per page (requesting one extra
|
||||||
|
* to detect "has more" — see splitPage), so a Project with a long history doesn't pull
|
||||||
|
* thousands of rows on every load; `loadMoreFor` appends the next page(s) on demand
|
||||||
|
* (deduplicated by sessionId — new sessions shift server offsets). A reload resets every
|
||||||
|
* Agent back to its first page.
|
||||||
|
*
|
||||||
* **Sessions are not auto-created here**: a new conversation starts as a draft (chat page `/chat/new`),
|
* **Sessions are not auto-created here**: a new conversation starts as a draft (chat page `/chat/new`),
|
||||||
* and the Session is only actually created when the first message is sent — after landing, the user
|
* and the Session is only actually created when the first message is sent — after landing, the user
|
||||||
* may still switch models or configure an API key first, so persisting the Session early would both
|
* may still switch models or configure an API key first, so persisting the Session early would both
|
||||||
@@ -21,15 +27,20 @@ import type { ReactNode } from "react";
|
|||||||
import type { SessionInfo, SessionStatus } from "@prismshadow/penguin-server/api";
|
import type { SessionInfo, SessionStatus } from "@prismshadow/penguin-server/api";
|
||||||
import * as api from "../api/endpoints";
|
import * as api from "../api/endpoints";
|
||||||
import { openUserEvents } from "../api/sse";
|
import { openUserEvents } from "../api/sse";
|
||||||
|
import { SIDEBAR_PAGE_SIZE, splitPage } from "../lib/session-grouping";
|
||||||
import { useProject } from "./project";
|
import { useProject } from "./project";
|
||||||
|
|
||||||
interface SessionsContextValue {
|
interface SessionsContextValue {
|
||||||
/** Full list (each Agent's entries keep server-side reverse chronological order). */
|
/** Loaded list (paged per Agent; each Agent's entries newest first). */
|
||||||
sessions: SessionInfo[];
|
sessions: SessionInfo[];
|
||||||
/** agentId → that Agent's Session list (empty array if none). */
|
/** agentId → that Agent's loaded Session list, newest first (empty array if none). */
|
||||||
byAgent: ReadonlyMap<string, SessionInfo[]>;
|
byAgent: ReadonlyMap<string, SessionInfo[]>;
|
||||||
|
/** agentId → whether the server has more (unfetched) sessions for that Agent. */
|
||||||
|
hasMoreByAgent: ReadonlyMap<string, boolean>;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
reload: () => Promise<void>;
|
reload: () => Promise<void>;
|
||||||
|
/** Fetches and appends the next page for each given Agent that still has more (no-op otherwise). */
|
||||||
|
loadMoreFor: (agentIds: string[]) => Promise<void>;
|
||||||
/** Prepend to the list on success (draft materialized by the first message, or explicit creation via dialog). */
|
/** Prepend to the list on success (draft materialized by the first message, or explicit creation via dialog). */
|
||||||
add: (session: SessionInfo) => void;
|
add: (session: SessionInfo) => void;
|
||||||
/** Remove from the list in place after deletion. */
|
/** Remove from the list in place after deletion. */
|
||||||
@@ -52,10 +63,16 @@ export function SessionsProvider({ children }: { children: ReactNode }) {
|
|||||||
const agentIdsKey = agents.map((a) => a.agentId).join(",");
|
const agentIdsKey = agents.map((a) => a.agentId).join(",");
|
||||||
|
|
||||||
const [sessions, setSessions] = useState<SessionInfo[]>([]);
|
const [sessions, setSessions] = useState<SessionInfo[]>([]);
|
||||||
|
const [hasMoreByAgent, setHasMoreByAgent] = useState<ReadonlyMap<string, boolean>>(new Map());
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
// Generation counter: invalidates any in-flight response once the Project/Agent set
|
// Generation counter: invalidates any in-flight response once the Project/Agent set
|
||||||
// changes or a reload happens.
|
// changes or a reload happens.
|
||||||
const gen = useRef(0);
|
const gen = useRef(0);
|
||||||
|
// Current values for loadMoreFor (offsets are computed from what is actually loaded).
|
||||||
|
const sessionsRef = useRef<SessionInfo[]>([]);
|
||||||
|
sessionsRef.current = sessions;
|
||||||
|
const hasMoreRef = useRef<ReadonlyMap<string, boolean>>(hasMoreByAgent);
|
||||||
|
hasMoreRef.current = hasMoreByAgent;
|
||||||
|
|
||||||
const reload = useCallback(async () => {
|
const reload = useCallback(async () => {
|
||||||
const agentIds = agentIdsKey === "" ? [] : agentIdsKey.split(",");
|
const agentIds = agentIdsKey === "" ? [] : agentIdsKey.split(",");
|
||||||
@@ -66,14 +83,23 @@ export function SessionsProvider({ children }: { children: ReactNode }) {
|
|||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
agentIds.map(async (agentId) => {
|
agentIds.map(async (agentId) => {
|
||||||
try {
|
try {
|
||||||
return (await api.listSessions(projectId, agentId)).sessions;
|
// First page per Agent, requesting one extra row to learn whether more exist.
|
||||||
|
const fetched = (
|
||||||
|
await api.listSessions(projectId, agentId, {
|
||||||
|
offset: 0,
|
||||||
|
limit: SIDEBAR_PAGE_SIZE + 1,
|
||||||
|
})
|
||||||
|
).sessions;
|
||||||
|
return { agentId, ...splitPage(fetched, SIDEBAR_PAGE_SIZE) };
|
||||||
} catch {
|
} catch {
|
||||||
return []; // A single Agent's fetch failure shouldn't bring down the whole batch (e.g. its directory was deleted externally).
|
// A single Agent's fetch failure shouldn't bring down the whole batch (e.g. its directory was deleted externally).
|
||||||
|
return { agentId, items: [] as SessionInfo[], hasMore: false };
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
if (g !== gen.current) return;
|
if (g !== gen.current) return;
|
||||||
setSessions(results.flat());
|
setSessions(results.flatMap((r) => r.items));
|
||||||
|
setHasMoreByAgent(new Map(results.map((r) => [r.agentId, r.hasMore])));
|
||||||
} finally {
|
} finally {
|
||||||
if (g === gen.current) setLoading(false);
|
if (g === gen.current) setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -81,9 +107,54 @@ export function SessionsProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSessions([]);
|
setSessions([]);
|
||||||
|
setHasMoreByAgent(new Map());
|
||||||
void reload();
|
void reload();
|
||||||
}, [reload]);
|
}, [reload]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Next page for each given Agent that still has more. Offsets are the currently loaded
|
||||||
|
* per-Agent counts; a session created since the last page shifts server offsets, so
|
||||||
|
* appended rows are deduplicated by sessionId (a short page is fine — `hasMore` comes
|
||||||
|
* from the server response, and the next click continues from the new count).
|
||||||
|
*/
|
||||||
|
const loadMoreFor = useCallback(
|
||||||
|
async (agentIds: string[]) => {
|
||||||
|
if (!projectId) return;
|
||||||
|
const targets = [...new Set(agentIds)].filter((id) => hasMoreRef.current.get(id) === true);
|
||||||
|
if (targets.length === 0) return;
|
||||||
|
const g = gen.current;
|
||||||
|
const results = await Promise.all(
|
||||||
|
targets.map(async (agentId) => {
|
||||||
|
const offset = sessionsRef.current.filter((s) => s.agentId === agentId).length;
|
||||||
|
try {
|
||||||
|
const fetched = (
|
||||||
|
await api.listSessions(projectId, agentId, {
|
||||||
|
offset,
|
||||||
|
limit: SIDEBAR_PAGE_SIZE + 1,
|
||||||
|
})
|
||||||
|
).sessions;
|
||||||
|
return { agentId, ...splitPage(fetched, SIDEBAR_PAGE_SIZE) };
|
||||||
|
} catch {
|
||||||
|
// Transient failure: keep "has more" so the affordance stays and the user can retry.
|
||||||
|
return { agentId, items: [] as SessionInfo[], hasMore: true };
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (g !== gen.current) return; // Project switch / reload raced this page: drop it.
|
||||||
|
setSessions((prev) => {
|
||||||
|
const seen = new Set(prev.map((s) => s.sessionId));
|
||||||
|
const appended = results.flatMap((r) => r.items.filter((s) => !seen.has(s.sessionId)));
|
||||||
|
return appended.length > 0 ? [...prev, ...appended] : prev;
|
||||||
|
});
|
||||||
|
setHasMoreByAgent((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
for (const r of results) next.set(r.agentId, r.hasMore);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[projectId],
|
||||||
|
);
|
||||||
|
|
||||||
// User-level event stream (/api/events): a scheduled task firing may have created a new
|
// User-level event stream (/api/events): a scheduled task firing may have created a new
|
||||||
// Session (new-session mode); reload the list so it appears immediately. schedule_queued
|
// Session (new-session mode); reload the list so it appears immediately. schedule_queued
|
||||||
// doesn't change the list (the target Session already exists), so it's ignored.
|
// doesn't change the list (the target Session already exists), so it's ignored.
|
||||||
@@ -140,18 +211,39 @@ export function SessionsProvider({ children }: { children: ReactNode }) {
|
|||||||
if (list) list.push(s);
|
if (list) list.push(s);
|
||||||
else byAgent.set(s.agentId, [s]);
|
else byAgent.set(s.agentId, [s]);
|
||||||
}
|
}
|
||||||
|
// Encounter order is no longer reliable with paging (appended pages are older, but a
|
||||||
|
// deep-linked old session is prepended via add): sort each Agent's list newest first
|
||||||
|
// (same key the server sorts by).
|
||||||
|
for (const list of byAgent.values()) {
|
||||||
|
list.sort(
|
||||||
|
(a, b) => b.createdAt.localeCompare(a.createdAt) || b.sessionId.localeCompare(a.sessionId),
|
||||||
|
);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
sessions,
|
sessions,
|
||||||
byAgent,
|
byAgent,
|
||||||
|
hasMoreByAgent,
|
||||||
loading,
|
loading,
|
||||||
reload,
|
reload,
|
||||||
|
loadMoreFor,
|
||||||
add,
|
add,
|
||||||
remove,
|
remove,
|
||||||
replace,
|
replace,
|
||||||
setStatus,
|
setStatus,
|
||||||
setTitle,
|
setTitle,
|
||||||
};
|
};
|
||||||
}, [sessions, loading, reload, add, remove, replace, setStatus, setTitle]);
|
}, [
|
||||||
|
sessions,
|
||||||
|
hasMoreByAgent,
|
||||||
|
loading,
|
||||||
|
reload,
|
||||||
|
loadMoreFor,
|
||||||
|
add,
|
||||||
|
remove,
|
||||||
|
replace,
|
||||||
|
setStatus,
|
||||||
|
setTitle,
|
||||||
|
]);
|
||||||
|
|
||||||
return <SessionsContext.Provider value={value}>{children}</SessionsContext.Provider>;
|
return <SessionsContext.Provider value={value}>{children}</SessionsContext.Provider>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,10 +11,14 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { SessionInfo } from "@prismshadow/penguin-server/api";
|
import type { SessionInfo } from "@prismshadow/penguin-server/api";
|
||||||
import {
|
import {
|
||||||
|
SIDEBAR_PAGE_SIZE,
|
||||||
TEMP_WORKSPACE_GROUP_KEY,
|
TEMP_WORKSPACE_GROUP_KEY,
|
||||||
|
groupAgentsWithMore,
|
||||||
groupSessionsByWorkspace,
|
groupSessionsByWorkspace,
|
||||||
isTempWorkspace,
|
isTempWorkspace,
|
||||||
|
partitionSessions,
|
||||||
pinnedFirst,
|
pinnedFirst,
|
||||||
|
splitPage,
|
||||||
workspaceGroupKey,
|
workspaceGroupKey,
|
||||||
workspaceLabel,
|
workspaceLabel,
|
||||||
} from "../src/lib/session-grouping";
|
} from "../src/lib/session-grouping";
|
||||||
@@ -23,7 +27,12 @@ let seq = 0;
|
|||||||
function session(
|
function session(
|
||||||
workspace: string,
|
workspace: string,
|
||||||
createdAt: string,
|
createdAt: string,
|
||||||
over: { sessionId?: string; agentId?: string; archived?: boolean } = {},
|
over: {
|
||||||
|
sessionId?: string;
|
||||||
|
agentId?: string;
|
||||||
|
archived?: boolean;
|
||||||
|
source?: "schedule" | "subagent";
|
||||||
|
} = {},
|
||||||
): SessionInfo {
|
): SessionInfo {
|
||||||
seq += 1;
|
seq += 1;
|
||||||
return {
|
return {
|
||||||
@@ -39,6 +48,7 @@ function session(
|
|||||||
pendingApprovalCount: 0,
|
pendingApprovalCount: 0,
|
||||||
hasTrace: false,
|
hasTrace: false,
|
||||||
archived: over.archived ?? false,
|
archived: over.archived ?? false,
|
||||||
|
...(over.source !== undefined ? { source: over.source } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,6 +148,78 @@ describe("groupSessionsByWorkspace", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("partitionSessions (per-group user / subagent / scheduled / archived split)", () => {
|
||||||
|
it("splits user rows and one bucket per origin, preserving order within each part", () => {
|
||||||
|
const user1 = session("/srv/alpha", "2026-07-06T10:00:00.000Z");
|
||||||
|
const sched1 = session("/srv/alpha", "2026-07-05T10:00:00.000Z", { source: "schedule" });
|
||||||
|
const sub1 = session("/srv/alpha", "2026-07-04T10:00:00.000Z", { source: "subagent" });
|
||||||
|
const user2 = session("/srv/alpha", "2026-07-03T10:00:00.000Z");
|
||||||
|
const sub2 = session("/srv/alpha", "2026-07-02T10:00:00.000Z", { source: "subagent" });
|
||||||
|
const gone = session("/srv/alpha", "2026-07-01T10:00:00.000Z", { archived: true });
|
||||||
|
const parts = partitionSessions([user1, sched1, sub1, user2, sub2, gone]);
|
||||||
|
expect(parts.active.map((s) => s.sessionId)).toEqual([user1.sessionId, user2.sessionId]);
|
||||||
|
expect(parts.subagent.map((s) => s.sessionId)).toEqual([sub1.sessionId, sub2.sessionId]);
|
||||||
|
expect(parts.schedule.map((s) => s.sessionId)).toEqual([sched1.sessionId]);
|
||||||
|
expect(parts.archived.map((s) => s.sessionId)).toEqual([gone.sessionId]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("archived wins over source: an archived automation-created session goes to the Archived folder only", () => {
|
||||||
|
const sub = session("/srv/alpha", "2026-07-01T10:00:00.000Z", {
|
||||||
|
source: "subagent",
|
||||||
|
archived: true,
|
||||||
|
});
|
||||||
|
const sched = session("/srv/alpha", "2026-07-02T10:00:00.000Z", {
|
||||||
|
source: "schedule",
|
||||||
|
archived: true,
|
||||||
|
});
|
||||||
|
const parts = partitionSessions([sub, sched]);
|
||||||
|
expect(parts.subagent).toEqual([]);
|
||||||
|
expect(parts.schedule).toEqual([]);
|
||||||
|
expect(parts.archived.map((s) => s.sessionId)).toEqual([sub.sessionId, sched.sessionId]);
|
||||||
|
expect(parts.active).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("empty input yields four empty parts", () => {
|
||||||
|
expect(partitionSessions([])).toEqual({
|
||||||
|
active: [],
|
||||||
|
subagent: [],
|
||||||
|
schedule: [],
|
||||||
|
archived: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("splitPage (limit+1 fetch trick)", () => {
|
||||||
|
it("an overflow row proves the server has more and is never shown", () => {
|
||||||
|
const fetched = [1, 2, 3, 4];
|
||||||
|
expect(splitPage(fetched, 3)).toEqual({ items: [1, 2, 3], hasMore: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a short or exactly-full page means the server is exhausted", () => {
|
||||||
|
expect(splitPage([1, 2], 3)).toEqual({ items: [1, 2], hasMore: false });
|
||||||
|
expect(splitPage([1, 2, 3], 3)).toEqual({ items: [1, 2, 3], hasMore: false });
|
||||||
|
expect(splitPage([], SIDEBAR_PAGE_SIZE)).toEqual({ items: [], hasMore: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("groupAgentsWithMore (workspace groups span Agents)", () => {
|
||||||
|
it("returns each contributing Agent with unfetched pages once; others are skipped", () => {
|
||||||
|
const rows = [
|
||||||
|
session("/srv/alpha", "2026-07-03T10:00:00.000Z", { agentId: "agent_a" }),
|
||||||
|
session("/srv/alpha", "2026-07-02T10:00:00.000Z", { agentId: "agent_b" }),
|
||||||
|
session("/srv/alpha", "2026-07-01T10:00:00.000Z", { agentId: "agent_a" }),
|
||||||
|
];
|
||||||
|
const hasMore = new Map([
|
||||||
|
["agent_a", true],
|
||||||
|
["agent_b", false],
|
||||||
|
["agent_c", true], // not contributing to this group
|
||||||
|
]);
|
||||||
|
expect(groupAgentsWithMore(rows, hasMore)).toEqual(["agent_a"]);
|
||||||
|
expect(groupAgentsWithMore(rows, new Map())).toEqual([]);
|
||||||
|
expect(groupAgentsWithMore([], hasMore)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("pinnedFirst (stable pinned-before-unpinned partition)", () => {
|
describe("pinnedFirst (stable pinned-before-unpinned partition)", () => {
|
||||||
const items = [{ k: "a" }, { k: "b" }, { k: "c" }, { k: "d" }];
|
const items = [{ k: "a" }, { k: "b" }, { k: "c" }, { k: "d" }];
|
||||||
const keyOf = (i: { k: string }) => i.k;
|
const keyOf = (i: { k: string }) => i.k;
|
||||||
|
|||||||
Reference in new issue
Block a user