diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index 6b9a6d8..72f9f9c 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -126,6 +126,8 @@ export interface CreateSessionOptions { baseUrl?: string; /** Internal use: this Session's depth in the subagent spawn chain (0 at the top level), used to cap spawn depth. */ 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 { @@ -298,6 +300,7 @@ export class Agent { thinking_level: thinkingLevel ?? "default", agent_state: this.state.stateDir, workspace: workspaceDir, + ...(opts.source !== undefined ? { source: opts.source } : {}), }, llm: rt.llm, environment: rt.environment, @@ -444,6 +447,12 @@ export class Agent { thinking_level: thinkingLevel ?? "default", agent_state: this.state.stateDir, 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, environment: rt.environment, @@ -573,6 +582,7 @@ export class Agent { ...childModel, thinkingLevel: thinkingLevel ?? null, subagentDepth: subagentDepth + 1, + source: "subagent", }); // 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 diff --git a/packages/core/src/omnimessage/types.ts b/packages/core/src/omnimessage/types.ts index 7cdd613..bf0943e 100644 --- a/packages/core/src/omnimessage/types.ts +++ b/packages/core/src/omnimessage/types.ts @@ -92,6 +92,8 @@ export interface SessionMetaPayload { agent_state: string; /** Absolute path to the Workspace. */ workspace: string; + /** Session origin: spawned by a subagent / triggered by a scheduled task; absent = user-created. */ + source?: "subagent" | "schedule"; } // --------------------------------------------------------------------------- diff --git a/packages/core/test/agent.test.ts b/packages/core/test/agent.test.ts index 8cc5c7c..6852087 100644 --- a/packages/core/test/agent.test.ts +++ b/packages/core/test/agent.test.ts @@ -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)).toBe( + false, + ); + } finally { + plain.dispose(); + } + }); +}); + describe("Agent.createSession thinking level (explicit option wins over the Agent config)", () => { const uniThinkingOf = (llm: unknown): unknown => ((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( runner: SubagentRunner, input: Parameters[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); try { 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; thinking_level: string; workspace: string; + source?: string; }; } finally { handle.dispose(); @@ -320,6 +352,9 @@ describe("run_subagent spawning follows the PARENT session (never the Project de expect(child.thinking_level).toBe("high"); // Workspace inheritance (behavior that predates model/thinking inheritance): locked here. 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 { parent.dispose(); } diff --git a/packages/core/test/resume.test.ts b/packages/core/test/resume.test.ts index f8fc271..72a8eaa 100644 --- a/packages/core/test/resume.test.ts +++ b/packages/core/test/resume.test.ts @@ -77,7 +77,12 @@ async function writeTraceFile( 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({ session_id: sessionId, provider: model.provider, @@ -88,6 +93,7 @@ function metaFor(sessionId: string, workspaceDir: string, model = MODEL): OmniMe thinking_level: "default", agent_state: "/agent/state", workspace: workspaceDir, + ...(source !== undefined ? { source } : {}), }); } @@ -116,6 +122,43 @@ describe("agent.resumeSession", () => { 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)).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)).toBe( + false, + ); + junk.dispose(); + }); + it("keeps abort events in resumed render history", async () => { const agent = await createAgent({}); await writeTraceFile(tmpRoot, SID, [ diff --git a/packages/docs/content/omni-message.en.md b/packages/docs/content/omni-message.en.md index 83d7194..ffb0248 100644 --- a/packages/docs/content/omni-message.en.md +++ b/packages/docs/content/omni-message.en.md @@ -41,6 +41,7 @@ interface SessionMetaPayload { thinking_level: string; // "default" when unconfigured agent_state: string; // absolute path of the Agent State workspace: string; // absolute path of the Workspace + source?: "subagent" | "schedule"; // session origin; absent = user-created } interface ToolDefinition { diff --git a/packages/docs/content/omni-message.zh.md b/packages/docs/content/omni-message.zh.md index 53e538b..9e1e6ec 100644 --- a/packages/docs/content/omni-message.zh.md +++ b/packages/docs/content/omni-message.zh.md @@ -41,6 +41,7 @@ interface SessionMetaPayload { thinking_level: string; // 未配置时为 "default" agent_state: string; // Agent State 绝对路径 workspace: string; // Workspace 绝对路径 + source?: "subagent" | "schedule"; // Session 来源;缺省 = 用户创建 } interface ToolDefinition { diff --git a/packages/server/src/api/types.ts b/packages/server/src/api/types.ts index 710be5d..fca96cd 100644 --- a/packages/server/src/api/types.ts +++ b/packages/server/src/api/types.ts @@ -454,7 +454,7 @@ export interface SessionInfo { approvalMode: ApprovalMode; /** Short title auto-generated by the model after the first turn; unset until generated (frontend shows "New Chat"). */ 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; createdAt: string; status: SessionStatus; diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index 7188b51..5161987 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -50,6 +50,7 @@ import { agentSessionsRoutes, sessionsRoutes } from "./http/routes/sessions.js"; import { ChannelHub } from "./runtime/channel.js"; import { ErrorRecorder } from "./runtime/error-recorder.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 { Scheduler } from "./runtime/scheduler.js"; import { TitleGenerator } from "./runtime/title-generator.js"; @@ -91,6 +92,8 @@ export interface AppDeps { scheduler: Scheduler; channels: ChannelHub; 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). */ errors: ErrorRecorder; /** Request log output (minimal one-liner); tests inject a noop. */ @@ -149,10 +152,15 @@ export function buildAppDeps(config: ServerConfig, overrides: BuildDepsOverrides const titles = overrides.titles ?? 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({ sessions: sessionsRepo, channels, - loader: overrides.loader ?? createCoreSessionLoader(config.root), + loader: overrides.loader ?? createCoreSessionLoader(config.root, sessionSources), + sources: sessionSources, recorder, errors, titles, @@ -194,6 +202,7 @@ export function buildAppDeps(config: ServerConfig, overrides: BuildDepsOverrides sessions: sessionsRepo, manager, projectConfig: projectConfigService, + sources: sessionSources, }); // 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). @@ -232,6 +241,7 @@ export function buildAppDeps(config: ServerConfig, overrides: BuildDepsOverrides scheduler, channels, manager, + sessionSources, errors, log, }; diff --git a/packages/server/src/db/repos/sessions.ts b/packages/server/src/db/repos/sessions.ts index 6980a41..522b4c9 100644 --- a/packages/server/src/db/repos/sessions.ts +++ b/packages/server/src/db/repos/sessions.ts @@ -19,8 +19,8 @@ export interface SessionRow { title: string | null; /** Archive timestamp, ISO; NULL = not archived (omitting on insert defaults to NULL). */ archivedAt?: string | null; - /** Session origin: NULL = user-created; schedule = triggered by a Schedule; subagent = registered as a subagent session. */ - source?: "schedule" | "subagent" | null; + // The Session origin (schedule / subagent) is deliberately NOT a row field: core + // session_meta in the Trace is the single source of truth (runtime/session-sources.ts). createdAt: string; } @@ -35,7 +35,6 @@ function mapRow(r: Record): SessionRow { approvalMode: r.approval_mode as ApprovalMode, title: (r.title 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, }; } @@ -46,8 +45,8 @@ export class SessionsRepo { insert(row: SessionRow): void { this.db .prepare( - `INSERT INTO sessions (session_id, project_id, agent_id, provider, model_id, workspace, approval_mode, title, source, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + `INSERT INTO sessions (session_id, project_id, agent_id, provider, model_id, workspace, approval_mode, title, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .run( row.sessionId, @@ -58,7 +57,6 @@ export class SessionsRepo { row.workspace, row.approvalMode, row.title, - row.source ?? null, row.createdAt, ); } @@ -67,8 +65,8 @@ export class SessionsRepo { insertOrIgnore(row: SessionRow): void { this.db .prepare( - `INSERT OR IGNORE INTO sessions (session_id, project_id, agent_id, provider, model_id, workspace, approval_mode, title, source, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + `INSERT OR IGNORE INTO sessions (session_id, project_id, agent_id, provider, model_id, workspace, approval_mode, title, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .run( row.sessionId, @@ -79,7 +77,6 @@ export class SessionsRepo { row.workspace, row.approvalMode, row.title, - row.source ?? null, row.createdAt, ); } diff --git a/packages/server/src/db/schema.ts b/packages/server/src/db/schema.ts index b7304c8..d5ec503 100644 --- a/packages/server/src/db/schema.ts +++ b/packages/server/src/db/schema.ts @@ -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 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") - source TEXT, -- session origin: NULL=user-created | schedule (scheduled task) | subagent (child session) 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 ( id INTEGER PRIMARY KEY AUTOINCREMENT, ts TEXT NOT NULL, diff --git a/packages/server/src/http/routes/sessions.ts b/packages/server/src/http/routes/sessions.ts index 659e614..2df287f 100644 --- a/packages/server/src/http/routes/sessions.ts +++ b/packages/server/src/http/routes/sessions.ts @@ -30,6 +30,7 @@ import { sseEndpoint } from "../sse.js"; import { badRequest, optionalEnum, + optionalPagingQuery, optionalString, paginationQuery, pathParam, @@ -96,7 +97,14 @@ export function agentSessionsRoutes(deps: AppDeps): Hono { const agentId = requireValidId(c, "agentId"); deps.projectService.requireProjectAccess(c.var.user.userId, projectId); 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); }); @@ -167,7 +175,9 @@ export function sessionsRoutes(deps: AppDeps): Hono { app.get("/:sessionId", async (c) => { const row = resolveSession(c); 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) => { @@ -216,7 +226,7 @@ export function sessionsRoutes(deps: AppDeps): Hono { } const hasTrace = await deps.sessionService.hasTrace(updated); return c.json({ - session: deps.sessionService.toInfo(updated, hasTrace), + session: await deps.sessionService.toInfo(updated, hasTrace), } satisfies SessionResponse); }); @@ -244,6 +254,9 @@ export function sessionsRoutes(deps: AppDeps): Hono { { recursive: true, force: true }, ); 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 { deps.manager.endSessionDeletion(row.sessionId); } diff --git a/packages/server/src/http/validate.ts b/packages/server/src/http/validate.ts index 0d7a048..5dd7838 100644 --- a/packages/server/src/http/validate.ts +++ b/packages/server/src/http/validate.ts @@ -64,6 +64,28 @@ export function paginationQuery(c: Context): { offset: number; limit: number } { 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). */ export async function readJson(c: Context): Promise> { let body: unknown; diff --git a/packages/server/src/runtime/session-manager.ts b/packages/server/src/runtime/session-manager.ts index 7bb79a3..5b6ba23 100644 --- a/packages/server/src/runtime/session-manager.ts +++ b/packages/server/src/runtime/session-manager.ts @@ -48,6 +48,8 @@ import { ApprovalRegistry, makeApprove } from "./approvals.js"; import type { PendingApproval } from "./approvals.js"; import type { ChannelHub } from "./channel.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 type { TitleNotifier } from "./title-generator.js"; import type { UsageContext } from "./usage-recorder.js"; @@ -96,8 +98,13 @@ export interface SessionLoader { load(row: SessionRow): Promise; } -/** 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 { async load(row: SessionRow): Promise { 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.`, ); } + const knownSource = sources?.get(row.sessionId); try { return await agent.createSession({ workspaceDir: row.workspace, modelId: row.modelId, provider: row.provider, + // The rebuilt Session re-records a known origin in its fresh session_meta. + ...(knownSource != null ? { source: knownSource } : {}), }); } catch (err) { if (isMissingCredential(err)) throw modelCredentialMissing(row.modelId); @@ -168,6 +178,8 @@ export interface SessionManagerDeps { sessions: SessionsRepo; channels: ChannelHub; 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; /** Automatic Session title generation (optional: not injected in tests or when disabled). */ titles?: TitleNotifier; @@ -879,6 +891,12 @@ export class SessionManager { const p = msg.payload as SessionMetaPayload; const agentId = path.basename(path.dirname(p.agent_state)); 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({ sessionId: childSid, projectId: entry.projectId, @@ -890,7 +908,6 @@ export class SessionManager { // inserted with defaults (matches the convention for Sessions discovered by the CLI). approvalMode: "allow-all", title: null, - source: "subagent", createdAt: new Date().toISOString(), }); // Make the subagent appear immediately in the sidebar: notify via the parent @@ -900,7 +917,7 @@ export class SessionManager { projectId: entry.projectId, agentId, sessionId: childSid, - source: "subagent", + source, }); const child: ChildSession = { sessionId: childSid, diff --git a/packages/server/src/runtime/session-sources.ts b/packages/server/src/runtime/session-sources.ts new file mode 100644 index 0000000..3dbae53 --- /dev/null +++ b/packages/server/src/runtime/session-sources.ts @@ -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(); + + /** 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); + } +} diff --git a/packages/server/src/services/session-service.ts b/packages/server/src/services/session-service.ts index 36deabf..6936d8e 100644 --- a/packages/server/src/services/session-service.ts +++ b/packages/server/src/services/session-service.ts @@ -20,11 +20,14 @@ import { readTraceTolerant, tracesDir, } 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 { badRequest } from "../http/validate.js"; import type { SessionRow, SessionsRepo } from "../db/repos/sessions.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"; const TRACE_FILE_RE = /^(.+)_(\d{3})\.jsonl$/; @@ -44,13 +47,20 @@ export interface SessionServiceDeps { sessions: SessionsRepo; manager: SessionManager; projectConfig: ProjectConfigService; + /** In-process origin registry derived from session_meta (the DB stores no source column). */ + sources: SessionSources; } export class SessionService { 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 { + const source = await this.sourceOf(row, hasTrace); return { sessionId: row.sessionId, projectId: row.projectId, @@ -60,7 +70,7 @@ export class SessionService { workspace: row.workspace, approvalMode: row.approvalMode, ...(row.title !== null ? { title: row.title } : {}), - ...(row.source != null ? { source: row.source } : {}), + ...(source !== undefined ? { source } : {}), createdAt: row.createdAt, status: this.deps.manager.statusOf(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 { + 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). */ async hasTrace(row: SessionRow): Promise { const ids = await this.discoverTraceSessionIds(row.projectId, row.agentId); return ids.has(row.sessionId); } - /** List: DB ∪ Trace directory discovery, sorted by createdAt descending. */ - async listSessions(projectId: string, agentId: string): Promise { + /** + * 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 { const traceIds = await this.discoverTraceSessionIds(projectId, agentId); const rows = new Map( this.deps.sessions.listByAgent(projectId, agentId).map((r) => [r.sessionId, r]), @@ -89,11 +128,11 @@ export class SessionService { if (discovered) rows.set(sessionId, discovered); } - return [...rows.values()] - .sort( - (a, b) => b.createdAt.localeCompare(a.createdAt) || b.sessionId.localeCompare(a.sessionId), - ) - .map((row) => this.toInfo(row, traceIds.has(row.sessionId))); + const sorted = [...rows.values()].sort( + (a, b) => b.createdAt.localeCompare(a.createdAt) || b.sessionId.localeCompare(a.sessionId), + ); + const page = paging ? sorted.slice(paging.offset, paging.offset + paging.limit) : sorted; + return Promise.all(page.map((row) => this.toInfo(row, traceIds.has(row.sessionId)))); } /** @@ -196,6 +235,8 @@ export class SessionService { modelId, provider, ...(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) { // 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), ); } + // 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 = { sessionId: session.sessionId, projectId: args.projectId, @@ -218,7 +267,6 @@ export class SessionService { approvalMode: args.approvalMode ?? "allow-all", title: null, createdAt: new Date().toISOString(), - ...(args.source !== undefined ? { source: args.source } : {}), }; this.deps.sessions.insert(row); this.deps.manager.adopt(row, session); @@ -238,12 +286,16 @@ export class SessionService { 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, agentId: string, sessionId: string, - ): Promise { + ): Promise { const dir = tracesDir(this.deps.root, projectId, agentId); let earliest: { path: string; index: number } | null = null; for (const dateDir of await listDirsSafe(dir)) { @@ -263,13 +315,25 @@ export class SessionService { } catch { 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 { + const meta = await this.readTraceMeta(projectId, agentId, sessionId); if (!meta) return null; // An older Trace version's session_meta lacks provider (the model reference // 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 // old data can simply be deleted and recreated). 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 = { sessionId, projectId, diff --git a/packages/server/test/scheduler.test.ts b/packages/server/test/scheduler.test.ts index d684c7b..4071a5d 100644 --- a/packages/server/test/scheduler.test.ts +++ b/packages/server/test/scheduler.test.ts @@ -40,6 +40,7 @@ describe("scheduler", () => { workspace?: string; provider?: string; modelId?: string; + source?: "schedule"; }>; let events: Array<{ userId: string; event: ScheduleServerEvent }>; let errors: ErrorRecordArgs[]; @@ -288,13 +289,15 @@ describe("scheduler", () => { nowMs = T0 + 6 * MIN; await scheduler.tickOnce(); expect(created).toHaveLength(2); - // The file's model reference is passed straight through as a pair. + // The file's model reference is passed straight through as a pair; every schedule-opened + // session is marked with its origin (SessionService stores it on the row and in core session_meta). expect(created[0]).toMatchObject({ projectId: P, agentId: A, workspace: "/tmp/ws", provider: "custom", modelId: "m-bench", + source: "schedule", }); expect(started.map((s) => s.sessionId)).toEqual(["session-new-1", "session-new-2"]); }); diff --git a/packages/server/test/session-index.test.ts b/packages/server/test/session-index.test.ts index beec5a7..b5fb289 100644 --- a/packages/server/test/session-index.test.ts +++ b/packages/server/test/session-index.test.ts @@ -103,6 +103,140 @@ describe("session-index", () => { expect(list.sessions.map((s) => s.sessionId)).toContain(session.sessionId); }); + it("schedule-created Session: source derives from session_meta (registry), never from the DB row; user sessions carry none", async () => { + await configureModels(); + // The scheduler goes through SessionService.createSession directly (no HTTP route exposes source). + const info = await t.deps.sessionService.createSession({ + projectId, + agentId: "default_agent", + source: "schedule", + }); + expect(info.source).toBe("schedule"); + // The index row stores no origin: session_meta is the single source of truth. + const row = t.deps.sessionsRepo.findById(info.sessionId); + expect(row && "source" in row).toBe(false); + expect(t.deps.sessionSources.get(info.sessionId)).toBe("schedule"); + + // A user-created session (HTTP) has no source, and the list surfaces both accordingly. + const res = await api.post(base(), {}); + expect(res.status).toBe(201); + const { session: plain } = (await res.json()) as SessionCreateResponse; + const list = (await (await api.get(base())).json()) as SessionsResponse; + expect(list.sessions.find((s) => s.sessionId === info.sessionId)?.source).toBe("schedule"); + expect(list.sessions.find((s) => s.sessionId === plain.sessionId)?.source).toBeUndefined(); + }); + + it("source survives a restart via the Trace head: an indexed row unknown to this process derives it lazily from session_meta", async () => { + await configureModels(); + // Simulate a Session created by a previous process: the index row exists, but the + // in-process registry has never seen it — only its Trace's session_meta knows the origin. + const sid = "session-2026-07-02-09-00-00-feedc0de"; + t.deps.sessionsRepo.insert({ + sessionId: sid, + projectId, + agentId: "default_agent", + provider: "custom", + modelId: "m-x", + workspace: "/tmp/w-restart", + approvalMode: "allow-all", + title: null, + createdAt: "2026-07-02T09:00:00.000Z", + }); + const meta: SessionMetaPayload = { + session_id: sid, + model_id: "m-x", + provider: "custom", + model_context_window: 1000, + system_prompt: "", + tools: [], + thinking_level: "default", + agent_state: "/tmp/a", + workspace: "/tmp/w-restart", + source: "subagent", + }; + await writeTraceFile(t.root, projectId, "default_agent", "2026-07-02", sid, 1, [ + sessionMeta(meta), + userText("child work"), + ]); + const list = (await (await api.get(base())).json()) as SessionsResponse; + expect(list.sessions.find((s) => s.sessionId === sid)?.source).toBe("subagent"); + // The single-session endpoint derives it the same way (and the second read hits the registry). + const single = (await (await api.get(`/api/sessions/${sid}`)).json()) as SessionResponse; + expect(single.session.source).toBe("subagent"); + }); + + it("adoption derives source from the Trace meta, narrowing junk values to user-created", async () => { + await configureModels(); + // Discovered (no index row) with a valid origin: adoption records it. + const adopted = "session-2026-07-03-10-00-00-0badf00d"; + const sourced: SessionMetaPayload = { + session_id: adopted, + model_id: "m-cli", + provider: "custom", + model_context_window: 1000, + system_prompt: "", + tools: [], + thinking_level: "default", + agent_state: "/tmp/a", + workspace: "/tmp/w-cli", + source: "schedule", + }; + await writeTraceFile(t.root, projectId, "default_agent", "2026-07-03", adopted, 1, [ + sessionMeta(sourced), + userText("adopted"), + ]); + // Discovered with a junk source (untrusted on-disk data): narrowed to user-created. + const junk = "session-2026-07-03-11-00-00-0badf00e"; + const junkMeta = { + ...sourced, + session_id: junk, + source: "weird-origin", + } as unknown as SessionMetaPayload; + await writeTraceFile(t.root, projectId, "default_agent", "2026-07-03", junk, 1, [ + sessionMeta(junkMeta), + userText("junk"), + ]); + const list = (await (await api.get(base())).json()) as SessionsResponse; + expect(list.sessions.find((s) => s.sessionId === adopted)?.source).toBe("schedule"); + expect(list.sessions.find((s) => s.sessionId === junk)?.source).toBeUndefined(); + }); + + it("list paging: limit/offset slice the newest-first list; absent params keep the full list; invalid values 400", async () => { + await configureModels(); + // Three sessions with distinct createdAt ordering (insert directly for deterministic times). + const mk = (n: number) => ({ + sessionId: `session-2026-07-0${n}-08-00-00-aaaa000${n}`, + projectId, + agentId: "default_agent", + provider: "custom", + modelId: "m-page", + workspace: `/tmp/w-${n}`, + approvalMode: "allow-all" as const, + title: null, + createdAt: `2026-07-0${n}T08:00:00.000Z`, + }); + for (const n of [1, 2, 3]) t.deps.sessionsRepo.insert(mk(n)); + + const ids = async (qs: string) => { + const res = await api.get(`${base()}${qs}`); + expect(res.status).toBe(200); + const body = (await res.json()) as SessionsResponse; + return body.sessions.map((s) => s.sessionId); + }; + const all = await ids(""); + expect(all).toEqual([mk(3).sessionId, mk(2).sessionId, mk(1).sessionId]); // newest first, unpaged + expect(await ids("?limit=2")).toEqual(all.slice(0, 2)); + expect(await ids("?limit=2&offset=2")).toEqual(all.slice(2)); + expect(await ids("?limit=2&offset=9")).toEqual([]); // past the end: empty page, not an error + // The sidebar's limit+1 trick: one extra row answers "has more" without an envelope change. + expect((await ids("?limit=3")).length).toBe(3); + + for (const bad of ["?limit=0", "?limit=-1", "?limit=abc", "?limit=1001", "?offset=1"]) { + expect((await api.get(`${base()}${bad}`)).status, bad).toBe(400); + } + expect((await api.get(`${base()}?limit=2&offset=-1`)).status).toBe(400); + }); + it("half a model reference is 400: the missing half is never inferred", async () => { await configureModels(); // Only modelId: even though it names the one configured model, the provider is never diff --git a/packages/server/test/session-loader.test.ts b/packages/server/test/session-loader.test.ts index d2f6284..1eb14cd 100644 --- a/packages/server/test/session-loader.test.ts +++ b/packages/server/test/session-loader.test.ts @@ -10,6 +10,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { createAgent, saveProjectConfig, sessionMeta, userText } from "@prismshadow/penguin-core"; import type { SessionMetaPayload } from "@prismshadow/penguin-core"; import { createCoreSessionLoader } from "../src/runtime/session-manager.js"; +import { SessionSources } from "../src/runtime/session-sources.js"; import type { SessionRow } from "../src/db/repos/sessions.js"; import { HttpError } from "../src/http/errors.js"; import { makeTempRoot, writeTraceFile } from "./helpers.js"; @@ -99,4 +100,38 @@ describe("session-loader", () => { expect((err as HttpError).status).toBe(409); expect((err as HttpError).code).toBe("workspace_missing"); }); + + it("self-heal rebuild re-records a registry-known origin in the fresh session_meta; unknown stays absent", async () => { + // The anthropic pair constructs without a credential (the same pair session-index + // creates over HTTP); custom/m1 would demand a key at client construction. + await saveProjectConfig(root, PROJECT, { + default_model: { provider: "anthropic", model_id: "claude-sonnet-4-6" }, + models: [{ provider: "anthropic", model_id: "claude-sonnet-4-6", context_window: 1000 }], + }); + const ws = path.join(root, "ws-heal"); + await fs.mkdir(ws, { recursive: true }); + const healRow: SessionRow = { + ...row(ws), + modelId: "claude-sonnet-4-6", + provider: "anthropic", + }; + + // Origin known to this process (e.g. a schedule-created Session rebuilt after adoption): + // the fresh session_meta must re-record it — meta is the single source of truth, and the + // rebuilt Session's Trace is the only place the origin can survive. + const sources = new SessionSources(); + sources.set(SID, "schedule"); + const known = await createCoreSessionLoader(root, sources).load(healRow); + const knownMeta = (known as unknown as { metaMessage: { payload: { source?: string } } }) + .metaMessage; + expect(knownMeta.payload.source).toBe("schedule"); + (known as unknown as { dispose(): void }).dispose(); + + // No registry entry (e.g. the process restarted and no Trace was ever written): the + // rebuilt Session is unsourced — no source key is invented. + const unknown = await createCoreSessionLoader(root, new SessionSources()).load(healRow); + const unknownMeta = (unknown as unknown as { metaMessage: { payload: object } }).metaMessage; + expect("source" in unknownMeta.payload).toBe(false); + (unknown as unknown as { dispose(): void }).dispose(); + }); }); diff --git a/packages/server/test/session-manager.test.ts b/packages/server/test/session-manager.test.ts index a848042..bde8d88 100644 --- a/packages/server/test/session-manager.test.ts +++ b/packages/server/test/session-manager.test.ts @@ -33,6 +33,7 @@ import type { ChannelEvent } from "../src/runtime/channel.js"; import type { ErrorRecordArgs, ErrorSink } from "../src/runtime/error-recorder.js"; import { SessionManager } from "../src/runtime/session-manager.js"; import type { RuntimeSession, SessionLoader } from "../src/runtime/session-manager.js"; +import { SessionSources } from "../src/runtime/session-sources.js"; import type { TitleRequest } from "../src/runtime/title-generator.js"; import type { UsageContext } from "../src/runtime/usage-recorder.js"; import { waitFor } from "./helpers.js"; @@ -78,6 +79,7 @@ describe("session-manager", () => { let db: DatabaseSync; let sessions: SessionsRepo; let channels: ChannelHub; + let sources: SessionSources; let recorded: OmniMessage[]; let recordedCtx: UsageContext[]; @@ -85,6 +87,7 @@ describe("session-manager", () => { new SessionManager({ sessions, channels, + sources, loader, recorder: { record: async (ctx, msg) => { @@ -114,6 +117,7 @@ describe("session-manager", () => { sessions = new SessionsRepo(db); sessions.insert(ROW); channels = new ChannelHub(); + sources = new SessionSources(); recorded = []; recordedCtx = []; }); @@ -287,6 +291,7 @@ describe("session-manager", () => { const manager = new SessionManager({ sessions, channels, + sources, loader: loaderOf(fake), recorder: { record: async () => {} }, titles: { @@ -301,6 +306,11 @@ describe("session-manager", () => { expect(child?.agentId).toBe("child_agent"); expect(child?.modelId).toBe("m-child"); expect(child?.workspace).toBe("/tmp/w-child"); + // The origin lands in the in-process registry (session_meta is the single source of + // truth; the row stores no source column) — "subagent" even when the forwarded meta + // predates the source field (this fake omits it): the registration path is the fallback. + expect(sources.get("child-1")).toBe("subagent"); + expect(child && "source" in child).toBe(false); // Title left blank: produced by the title generator from the sub-session's own conversation (falls back to the prompt's first line on failure). expect(child?.title).toBeNull(); @@ -577,6 +587,7 @@ describe("session-manager", () => { const manager = new SessionManager({ sessions, channels, + sources, loader: loaderOf(plainSession), recorder: { record: async () => {} }, titles: { @@ -629,6 +640,7 @@ describe("session-manager", () => { const manager = new SessionManager({ sessions, channels, + sources, loader: loaderOf(longSession), recorder: { record: async () => {} }, titles: { @@ -695,6 +707,7 @@ describe("session-manager", () => { const manager = new SessionManager({ sessions, channels, + sources, loader: loaderOf(delegating), recorder: { record: async (_ctx, msg) => { diff --git a/packages/web/e2e/paging.spec.mjs b/packages/web/e2e/paging.spec.mjs new file mode 100644 index 0000000..022e524 --- /dev/null +++ b/packages/web/e2e/paging.spec.mjs @@ -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); +}); diff --git a/packages/web/e2e/subagent.spec.mjs b/packages/web/e2e/subagent.spec.mjs index bbffc34..c2134d8 100644 --- a/packages/web/e2e/subagent.spec.mjs +++ b/packages/web/e2e/subagent.spec.mjs @@ -105,11 +105,39 @@ test("subagent card survives a reload; child session title is generated from its await openSubagent(); // --- 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 ( await page.request.get(`${BASE}/api/projects/${projectId}/agents/${agentId}/sessions`) ).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(); }); diff --git a/packages/web/src/api/endpoints.ts b/packages/web/src/api/endpoints.ts index 44e3d3b..5922bbe 100644 --- a/packages/web/src/api/endpoints.ts +++ b/packages/web/src/api/endpoints.ts @@ -185,9 +185,16 @@ export const getAgentTraces = (projectId: string, agentId: string) => // 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( - `/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. */ diff --git a/packages/web/src/components/layout/sidebar.tsx b/packages/web/src/components/layout/sidebar.tsx index 14d40a5..086d7ce 100644 --- a/packages/web/src/components/layout/sidebar.tsx +++ b/packages/web/src/components/layout/sidebar.tsx @@ -15,10 +15,10 @@ * 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. */ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import type { ReactNode } from "react"; 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 { ApiError } from "../../api/client"; 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 { useSessions } from "../../state/sessions"; import { + SIDEBAR_PAGE_SIZE, + groupAgentsWithMore, groupSessionsByWorkspace, + partitionSessions, pinnedFirst, workspaceGroupKey, } from "../../lib/session-grouping"; +import type { SessionPartition } from "../../lib/session-grouping"; import { Dropdown } from "../ui/dropdown"; import { AgentAvatar } from "../ui/agent-avatar"; import { Chevron } from "../ui/chevron"; @@ -147,6 +151,13 @@ function saveGroupSet(storageKey: string | null, next: ReadonlySet): 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. */ function StatusDot({ session }: { session: SessionInfo }) { if (session.status === "running") { @@ -189,7 +200,8 @@ export function Sidebar({ currentAgent, setCurrentAgentId, } = useProject(); - const { sessions, byAgent, loading, remove, replace } = useSessions(); + const { sessions, byAgent, hasMoreByAgent, loadMoreFor, loading, remove, replace } = + useSessions(); const chatMatch = useMatch("/chat/:sessionId"); const activeSessionId = chatMatch?.params.sessionId ?? null; @@ -218,6 +230,10 @@ export function Sidebar({ }, [collapseStoreKey, pinStoreKey]); /** Expanded "archived" groups (collapsed by default), keyed like collapsedGroups. */ const [openArchived, setOpenArchived] = useState>(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>(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>(new Map()); /** Session pending delete confirmation (null = none). */ const [deletingSession, setDeletingSession] = useState(null); const [deletingBusy, setDeletingBusy] = useState(false); @@ -278,6 +294,36 @@ export function Sidebar({ 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(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). */ 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. @@ -404,36 +450,108 @@ export function Sidebar({ ); - /** Expanded group body shared by both modes: active rows + the collapsed-by-default archived subgroup (keyed by the group key). */ - const renderGroupBody = ( + const folderClass = + "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, - activeList: SessionInfo[], - archivedList: SessionInfo[], + source: SessionSource, + rows: SessionInfo[], withAgentHint: boolean, + ) => { + if (rows.length === 0) return null; + const open = openSourceFolders.has(sourceFolderKey(groupKey, source)); + return ( +
+ + {open && renderRows(rows, withAgentHint)} +
+ ); + }; + + /** "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 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 ( <> - {activeList.length === 0 && archivedList.length === 0 ? ( + {empty ? (

{S.chat.noSessions}

) : ( - renderRows(activeList, withAgentHint) + renderRows(shownActive, withAgentHint) )} - {/* Archived group (collapsed by default) */} - {archivedList.length > 0 && ( + {/* Load/reveal more (kept adjacent to the active list it extends, above the folders) */} + {hasMore && ( + + )} + + {/* 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 && (
- {archivedOpen && renderRows(archivedList, withAgentHint)} + {archivedOpen && renderRows(parts.archived, withAgentHint)}
)} @@ -624,9 +742,7 @@ export function Sidebar({ ) : ( orderedAgents.map((agent) => { - const list = byAgent.get(agent.agentId) ?? []; - const activeList = list.filter((s) => !s.archived); - const archivedList = list.filter((s) => s.archived); + const parts = partitionSessions(byAgent.get(agent.agentId) ?? []); const collapsed = collapsedGroups.has(agent.agentId); const pinned = pinnedGroups.has(agent.agentId); return ( @@ -678,7 +794,12 @@ export function Sidebar({ {collapsed ? null - : renderGroupBody(agent.agentId, activeList, archivedList, false)} + : renderGroupBody( + agent.agentId, + parts, + false, + hasMoreByAgent.get(agent.agentId) === true ? [agent.agentId] : [], + )} ); }) @@ -691,8 +812,7 @@ export function Sidebar({

) : ( orderedWorkspaceGroups.map((group) => { - const activeList = group.sessions.filter((s) => !s.archived); - const archivedList = group.sessions.filter((s) => s.archived); + const parts = partitionSessions(group.sessions); const collapsed = collapsedGroups.has(group.key); const pinned = pinnedGroups.has(group.key); return ( @@ -717,8 +837,9 @@ export function Sidebar({ {group.temp ? S.chat.tempWorkspaces : group.label} + {/* Header count = non-archived Sessions (user + automation-created; unchanged semantics) */} - {activeList.length} + {parts.active.length + parts.subagent.length + parts.schedule.length} @@ -736,7 +857,15 @@ export function Sidebar({ - {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), + )} ); }) diff --git a/packages/web/src/features/chat/chat-page.tsx b/packages/web/src/features/chat/chat-page.tsx index 1793e99..48d06d9 100644 --- a/packages/web/src/features/chat/chat-page.tsx +++ b/packages/web/src/features/chat/chat-page.tsx @@ -182,13 +182,36 @@ export function ChatPage() { }; }, [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(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). useEffect(() => { if (sessionsLoading || draft) 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]; 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. useEffect(() => { diff --git a/packages/web/src/features/traces/trace-event-row.tsx b/packages/web/src/features/traces/trace-event-row.tsx index d161df9..2d2d18b 100644 --- a/packages/web/src/features/traces/trace-event-row.tsx +++ b/packages/web/src/features/traces/trace-event-row.tsx @@ -158,6 +158,8 @@ const summaryClass = function SessionMetaBody({ p }: { p: Record }) { const rows: Array<[string, string]> = [ ["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 ?? "")], ["context_window", String(p.model_context_window ?? "")], ["thinking_level", String(p.thinking_level ?? "")], diff --git a/packages/web/src/lib/session-grouping.ts b/packages/web/src/lib/session-grouping.ts index 04f0ba8..039bf1b 100644 --- a/packages/web/src/lib/session-grouping.ts +++ b/packages/web/src/lib/session-grouping.ts @@ -41,6 +41,72 @@ export function workspaceLabel(workspace: string): string { 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(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[] { + 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 { /** Stable group key: the Workspace path, or TEMP_WORKSPACE_GROUP_KEY for the merged temp group. */ key: string; diff --git a/packages/web/src/lib/strings-en.ts b/packages/web/src/lib/strings-en.ts index 02698da..df93009 100644 --- a/packages/web/src/lib/strings-en.ts +++ b/packages/web/src/lib/strings-en.ts @@ -700,6 +700,13 @@ When done, open index.html in a browser and self-test once.`, archiveSession: "Archive", unarchiveSession: "Unarchive", 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 => `Using skill${names.length === 1 ? "" : "s"}: ${names.join(", ")}`, }, diff --git a/packages/web/src/lib/strings.ts b/packages/web/src/lib/strings.ts index 6944a17..452ab98 100644 --- a/packages/web/src/lib/strings.ts +++ b/packages/web/src/lib/strings.ts @@ -680,6 +680,13 @@ Penguin 视觉风格(见 web-design 技能),深色/浅色主题( `已归档(${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("、")}`, }, diff --git a/packages/web/src/state/sessions.tsx b/packages/web/src/state/sessions.tsx index da0c178..06ed495 100644 --- a/packages/web/src/state/sessions.tsx +++ b/packages/web/src/state/sessions.tsx @@ -3,6 +3,12 @@ * 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. * + * **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`), * 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 @@ -21,15 +27,20 @@ import type { ReactNode } from "react"; import type { SessionInfo, SessionStatus } from "@prismshadow/penguin-server/api"; import * as api from "../api/endpoints"; import { openUserEvents } from "../api/sse"; +import { SIDEBAR_PAGE_SIZE, splitPage } from "../lib/session-grouping"; import { useProject } from "./project"; 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[]; - /** 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; + /** agentId → whether the server has more (unfetched) sessions for that Agent. */ + hasMoreByAgent: ReadonlyMap; loading: boolean; reload: () => Promise; + /** Fetches and appends the next page for each given Agent that still has more (no-op otherwise). */ + loadMoreFor: (agentIds: string[]) => Promise; /** Prepend to the list on success (draft materialized by the first message, or explicit creation via dialog). */ add: (session: SessionInfo) => void; /** 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 [sessions, setSessions] = useState([]); + const [hasMoreByAgent, setHasMoreByAgent] = useState>(new Map()); const [loading, setLoading] = useState(true); // Generation counter: invalidates any in-flight response once the Project/Agent set // changes or a reload happens. const gen = useRef(0); + // Current values for loadMoreFor (offsets are computed from what is actually loaded). + const sessionsRef = useRef([]); + sessionsRef.current = sessions; + const hasMoreRef = useRef>(hasMoreByAgent); + hasMoreRef.current = hasMoreByAgent; const reload = useCallback(async () => { const agentIds = agentIdsKey === "" ? [] : agentIdsKey.split(","); @@ -66,14 +83,23 @@ export function SessionsProvider({ children }: { children: ReactNode }) { const results = await Promise.all( agentIds.map(async (agentId) => { 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 { - 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; - setSessions(results.flat()); + setSessions(results.flatMap((r) => r.items)); + setHasMoreByAgent(new Map(results.map((r) => [r.agentId, r.hasMore]))); } finally { if (g === gen.current) setLoading(false); } @@ -81,9 +107,54 @@ export function SessionsProvider({ children }: { children: ReactNode }) { useEffect(() => { setSessions([]); + setHasMoreByAgent(new Map()); void 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 // 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. @@ -140,18 +211,39 @@ export function SessionsProvider({ children }: { children: ReactNode }) { if (list) list.push(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 { sessions, byAgent, + hasMoreByAgent, loading, reload, + loadMoreFor, add, remove, replace, setStatus, setTitle, }; - }, [sessions, loading, reload, add, remove, replace, setStatus, setTitle]); + }, [ + sessions, + hasMoreByAgent, + loading, + reload, + loadMoreFor, + add, + remove, + replace, + setStatus, + setTitle, + ]); return {children}; } diff --git a/packages/web/test/session-grouping.test.ts b/packages/web/test/session-grouping.test.ts index 43fb770..52b5ee2 100644 --- a/packages/web/test/session-grouping.test.ts +++ b/packages/web/test/session-grouping.test.ts @@ -11,10 +11,14 @@ import { describe, expect, it } from "vitest"; import type { SessionInfo } from "@prismshadow/penguin-server/api"; import { + SIDEBAR_PAGE_SIZE, TEMP_WORKSPACE_GROUP_KEY, + groupAgentsWithMore, groupSessionsByWorkspace, isTempWorkspace, + partitionSessions, pinnedFirst, + splitPage, workspaceGroupKey, workspaceLabel, } from "../src/lib/session-grouping"; @@ -23,7 +27,12 @@ let seq = 0; function session( workspace: string, createdAt: string, - over: { sessionId?: string; agentId?: string; archived?: boolean } = {}, + over: { + sessionId?: string; + agentId?: string; + archived?: boolean; + source?: "schedule" | "subagent"; + } = {}, ): SessionInfo { seq += 1; return { @@ -39,6 +48,7 @@ function session( pendingApprovalCount: 0, hasTrace: 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)", () => { const items = [{ k: "a" }, { k: "b" }, { k: "c" }, { k: "d" }]; const keyOf = (i: { k: string }) => i.k;