diff --git a/src/agentbox/http-server.test.ts b/src/agentbox/http-server.test.ts index 53a8a87c7..66509008b 100644 --- a/src/agentbox/http-server.test.ts +++ b/src/agentbox/http-server.test.ts @@ -403,6 +403,7 @@ describe("http-server — /health + /api/sessions + /api/models", () => { schemaVersion: AGENT_SYNC_STATUS_SCHEMA_VERSION, knowledge: { syncedAt: "2026-08-18T08:00:00.000Z", + sessionRefresh: { policy: "next_prompt", residentSessions: 0, pendingSessions: 0 }, repos: [{ id: "kb-1", name: "hardware", version: 2, sha256: "abc", fileCount: 12 }], }, skills: { names: ["k8s-debug"] }, diff --git a/src/agentbox/http-server.ts b/src/agentbox/http-server.ts index 5ca2f26d9..4ff453295 100644 --- a/src/agentbox/http-server.ts +++ b/src/agentbox/http-server.ts @@ -41,7 +41,7 @@ import { readBoxSyncStatus, type KnowledgeSyncHandler, } from "./sync-handlers.js"; -import type { ObservedMcpServer } from "../shared/agentbox-sync-status.js"; +import { knowledgeSessionRefresh, type ObservedMcpServer } from "../shared/agentbox-sync-status.js"; import { GATEWAY_SYNC_DESCRIPTORS, type AgentBoxSyncHandler, type GatewaySyncType } from "../shared/gateway-sync.js"; import { detectLanguage } from "../shared/detect-language.js"; import { stripLanguageDirective } from "../shared/strip-language-directive.js"; @@ -833,6 +833,7 @@ export function createHttpServer( const servers = liveManager ? liveManager.getServerConnections() : observedMcpServers; sendJson(res, 200, { ...inventory, + knowledge: { ...inventory.knowledge, sessionRefresh: knowledgeSessionRefresh(sessionManager.list()) }, mcp: { ...inventory.mcp, ...(servers ? { servers } : {}) }, model: observedModel, harness: observedHarness, diff --git a/src/shared/agentbox-sync-status.test.ts b/src/shared/agentbox-sync-status.test.ts index dc3cf46b5..88d1bedc4 100644 --- a/src/shared/agentbox-sync-status.test.ts +++ b/src/shared/agentbox-sync-status.test.ts @@ -8,7 +8,7 @@ * ways of "saying nothing" mean opposite things. */ import { describe, it, expect } from "vitest"; -import { AGENT_SYNC_STATUS_SCHEMA_VERSION, normalizeBoxSyncStatus } from "./agentbox-sync-status.js"; +import { AGENT_SYNC_STATUS_SCHEMA_VERSION, knowledgeSessionRefresh, normalizeBoxSyncStatus } from "./agentbox-sync-status.js"; const REV_A = "a".repeat(64); const REV_B = "b".repeat(64); @@ -165,3 +165,19 @@ describe("mcp.servers observation", () => { expect(status.mcp.servers?.[0].error).toEqual({ kind: "unknown", message: "boom" }); }); }); + + +describe("knowledge session refresh evidence", () => { + it("reports invalidated sessions separately from installed files", () => { + const refresh = knowledgeSessionRefresh([{ _invalidated: true }, { _invalidated: false }]); + expect(refresh).toEqual({ policy: "next_prompt", residentSessions: 2, pendingSessions: 1 }); + const status = normalizeBoxSyncStatus(payload({ knowledge: { repos: [], sessionRefresh: refresh } })); + expect(status.knowledge.sessionRefresh).toEqual(refresh); + }); + it("preserves missing legacy evidence and rejects inconsistent counts", () => { + expect(normalizeBoxSyncStatus(payload()).knowledge.sessionRefresh).toBeUndefined(); + expect(normalizeBoxSyncStatus(payload({ knowledge: { repos: [], sessionRefresh: { + policy: "next_prompt", residentSessions: 1, pendingSessions: 2, + } } })).knowledge.sessionRefresh).toBeUndefined(); + }); +}); diff --git a/src/shared/agentbox-sync-status.ts b/src/shared/agentbox-sync-status.ts index e81de9c27..d7eca07ba 100644 --- a/src/shared/agentbox-sync-status.ts +++ b/src/shared/agentbox-sync-status.ts @@ -30,7 +30,26 @@ export interface ObservedKnowledgeRepo { * session has initialized MCP; a v3 box cannot, so a consumer must not read an * absent `servers` as "all connected". */ -export const AGENT_SYNC_STATUS_SCHEMA_VERSION = 4; +// 5 adds knowledge.sessionRefresh: materialized files and deferred warm-session +// invalidation are distinct; older boxes leave this evidence absent. +export const AGENT_SYNC_STATUS_SCHEMA_VERSION = 5; + +/** Installed files and warm-session refresh are separate observations. */ +export interface KnowledgeSessionRefresh { + policy: "next_prompt"; + residentSessions: number; + pendingSessions: number; +} + +export function knowledgeSessionRefresh( + sessions: ReadonlyArray<{ _invalidated: boolean }>, +): KnowledgeSessionRefresh { + return { + policy: "next_prompt", + residentSessions: sessions.length, + pendingSessions: sessions.filter((session) => session._invalidated).length, + }; +} /** Closed vocabulary shared with core/mcp-client.ts; see McpConnectErrorKind. */ export type ObservedMcpErrorKind = @@ -64,6 +83,7 @@ export interface BoxSyncStatus { knowledge: { syncedAt: string | null; repos: ObservedKnowledgeRepo[]; + sessionRefresh?: KnowledgeSessionRefresh; }; skills: { names: string[] }; mcp: { @@ -193,6 +213,11 @@ export function normalizeBoxSyncStatus(value: unknown): BoxSyncStatus { if (!root) throw new Error("invalid AgentBox sync status"); const knowledge = record(root.knowledge); + const refresh = record(knowledge?.sessionRefresh); + const validRefresh = refresh?.policy === "next_prompt" + && Number.isSafeInteger(refresh.residentSessions) && (refresh.residentSessions as number) >= 0 + && Number.isSafeInteger(refresh.pendingSessions) && (refresh.pendingSessions as number) >= 0 + && (refresh.pendingSessions as number) <= (refresh.residentSessions as number); const repos = Array.isArray(knowledge?.repos) ? knowledge.repos.flatMap((item): ObservedKnowledgeRepo[] => { const repo = record(item); @@ -229,6 +254,11 @@ export function normalizeBoxSyncStatus(value: unknown): BoxSyncStatus { knowledge: { syncedAt: typeof knowledge?.syncedAt === "string" ? knowledge.syncedAt : null, repos, + ...(validRefresh ? { sessionRefresh: { + policy: "next_prompt" as const, + residentSessions: refresh!.residentSessions as number, + pendingSessions: refresh!.pendingSessions as number, + } } : {}), }, skills: { names: strings(record(root.skills)?.names) }, mcp: normalizeObservedMcp(record(root.mcp)),