Archived
Merge remote-tracking branch 'origin/main' into pr-36-generic-agent-config
# Conflicts: # docs/config.html # docs/config.md # src/cli.test.ts # src/cli.ts # src/client/src/components/settings/SettingsSessiondPanel.ts # src/client/src/components/settings/settingsConfigDraft.test.ts # src/client/src/components/settings/settingsConfigDraft.ts # src/server/app.test.ts # src/server/app.ts # src/server/configRoutes.test.ts # src/server/configRoutes.ts # src/server/piWebPluginService.test.ts # src/server/piWebPluginService.ts # src/server/piWebStatus.test.ts # src/server/piWebStatus.ts # src/server/piWebStatusCache.ts # src/server/sessions/authService.test.ts # src/server/sessions/piSessionService.ts # src/server/sessions/sessionRoutes.test.ts
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isSessionActive, sessionActivityLabel, isWorkspaceActivityActive } from "./activity";
|
||||
import { isSessionActive, isWorkspaceActivityActive } from "./activity";
|
||||
import type { SessionStatus, WorkspaceActivity } from "./apiTypes";
|
||||
|
||||
const idleStatus: SessionStatus = {
|
||||
@@ -14,17 +14,10 @@ const idleStatus: SessionStatus = {
|
||||
};
|
||||
|
||||
describe("activity helpers", () => {
|
||||
it("detects and labels active session states consistently", () => {
|
||||
it("detects active session states", () => {
|
||||
expect(isSessionActive(idleStatus)).toBe(false);
|
||||
expect(sessionActivityLabel(idleStatus)).toBeUndefined();
|
||||
|
||||
expect(isSessionActive({ ...idleStatus, isStreaming: true })).toBe(true);
|
||||
expect(sessionActivityLabel({ ...idleStatus, isStreaming: true })).toBe("streaming");
|
||||
|
||||
expect(isSessionActive({ ...idleStatus, pendingMessageCount: 2 })).toBe(true);
|
||||
expect(sessionActivityLabel({ ...idleStatus, pendingMessageCount: 2 })).toBe("2 pending");
|
||||
|
||||
expect(sessionActivityLabel(idleStatus, { sessionId: "s1", phase: "active", label: "running tool", detail: "read", at: "now" })).toBe("running tool: read");
|
||||
});
|
||||
|
||||
it("detects workspace activity presence without exposing details", () => {
|
||||
|
||||
@@ -8,16 +8,6 @@ export function isSessionActive(status?: SessionStatus, activity?: SessionActivi
|
||||
|| (status?.pendingMessageCount ?? 0) > 0;
|
||||
}
|
||||
|
||||
export function sessionActivityLabel(status?: SessionStatus, activity?: SessionActivity): string | undefined {
|
||||
if (activity?.phase === "active") return activity.detail !== undefined && activity.detail !== "" ? `${activity.label}: ${activity.detail}` : activity.label;
|
||||
if (status === undefined) return undefined;
|
||||
if (status.isCompacting) return "compacting";
|
||||
if (status.isBashRunning) return "bash";
|
||||
if (status.isStreaming) return "streaming";
|
||||
if (status.pendingMessageCount > 0) return `${String(status.pendingMessageCount)} pending`;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function isWorkspaceActivityActive(activity: WorkspaceActivity | undefined): boolean {
|
||||
return activity !== undefined && (activity.hasSessionActivity || activity.hasTerminalActivity);
|
||||
}
|
||||
|
||||
+76
-1
@@ -3,10 +3,14 @@ export type MachineStatus = "unknown" | "online" | "offline" | "error";
|
||||
|
||||
export const PI_WEB_CAPABILITIES = {
|
||||
sessionsDeleteArchived: "sessions.deleteArchived",
|
||||
sessionsBulkMutations: "sessions.bulkMutations",
|
||||
sessionsCleanup: "sessions.cleanup",
|
||||
sessionsReload: "sessions.reload",
|
||||
sessionsPersistedState: "sessions.persistedState",
|
||||
promptAttachments: "prompt.attachments",
|
||||
workspaceFileSuggestions: "workspace.fileSuggestions",
|
||||
piPackagesManage: "piPackages.manage",
|
||||
selectedMachineSettings: "settings.selectedMachine",
|
||||
} as const;
|
||||
|
||||
export type PiWebCapability = typeof PI_WEB_CAPABILITIES[keyof typeof PI_WEB_CAPABILITIES];
|
||||
@@ -108,6 +112,43 @@ export interface PiWebPluginsResponse {
|
||||
plugins: PiWebPluginInfo[];
|
||||
}
|
||||
|
||||
export type PiPackageScope = "user" | "project";
|
||||
|
||||
export interface PiPackageInfo {
|
||||
source: string;
|
||||
scope: PiPackageScope;
|
||||
filtered: boolean;
|
||||
installedPath?: string;
|
||||
}
|
||||
|
||||
export interface PiPackagesResponse {
|
||||
packages: PiPackageInfo[];
|
||||
}
|
||||
|
||||
export interface PiPackageInstallRequest {
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface PiPackageRemoveRequest {
|
||||
source: string;
|
||||
/** Optional known scope from a listed package; not an install-location picker. */
|
||||
scope?: PiPackageScope;
|
||||
}
|
||||
|
||||
export interface PiPackageUpdateRequest {
|
||||
/** Omit to update all configured Pi packages. */
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export type PiPackageMutationAction = "install" | "remove" | "update";
|
||||
|
||||
export interface PiPackageMutationResponse extends PiPackagesResponse {
|
||||
action: PiPackageMutationAction;
|
||||
source?: string;
|
||||
scope?: PiPackageScope;
|
||||
removed?: boolean;
|
||||
}
|
||||
|
||||
export interface PiWebConfigEnvOverrides {
|
||||
host: boolean;
|
||||
port: boolean;
|
||||
@@ -158,6 +199,8 @@ export interface SessionRef {
|
||||
|
||||
export interface SessionInfo extends SessionRef {
|
||||
path: string;
|
||||
/** True when the server has verified a backing session file exists; false when known transient. */
|
||||
persisted?: boolean;
|
||||
name?: string;
|
||||
created: string;
|
||||
modified: string;
|
||||
@@ -175,6 +218,34 @@ export interface ArchiveSessionsResponse {
|
||||
skippedAlreadyArchivedCount?: number;
|
||||
}
|
||||
|
||||
export interface SessionBulkMutationRef {
|
||||
id: string;
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
export interface SessionBulkMutationRequest {
|
||||
sessions: SessionBulkMutationRef[];
|
||||
}
|
||||
|
||||
export interface SessionBulkFailure {
|
||||
sessionId: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface SessionBulkArchiveResponse {
|
||||
archived: true;
|
||||
archivedSessionIds: string[];
|
||||
failures: SessionBulkFailure[];
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
export interface SessionBulkDeleteArchivedResponse {
|
||||
deleted: true;
|
||||
deletedSessionIds: string[];
|
||||
failures: SessionBulkFailure[];
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
export interface SessionCleanupRequest {
|
||||
/** Archive non-archived sessions whose modified time is older than this many days. Omit/null to disable. */
|
||||
archiveIdleDays?: number | null;
|
||||
@@ -324,6 +395,8 @@ export interface ThinkingLevelsResponse {
|
||||
|
||||
export interface SessionStatus {
|
||||
sessionId: string;
|
||||
/** True when the server has verified a backing session file exists; false when known transient. */
|
||||
persisted?: boolean;
|
||||
model?: SessionModel;
|
||||
thinkingLevel?: string;
|
||||
isStreaming: boolean;
|
||||
@@ -497,7 +570,8 @@ export interface TerminalCommandRunFilter {
|
||||
|
||||
export type PiWebServiceComponent = "web" | "sessiond";
|
||||
export type PiWebStatusSeverity = "info" | "warning" | "error";
|
||||
export type PiWebInstallationKind = "pi-package" | "npm-global" | "local" | "unknown";
|
||||
export type PiWebInstallationKind = "pi-package" | "npm-global" | "local" | "docker" | "unknown";
|
||||
export type PiWebDockerMode = "runtime" | "dev";
|
||||
|
||||
export interface PiWebInstallationInfo {
|
||||
kind: PiWebInstallationKind;
|
||||
@@ -505,6 +579,7 @@ export interface PiWebInstallationInfo {
|
||||
source?: string;
|
||||
scope?: "user" | "project";
|
||||
npmRoot?: string;
|
||||
dockerMode?: PiWebDockerMode;
|
||||
}
|
||||
|
||||
export interface PiWebComponentStatus {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { effectivePiWebCapabilities, PI_WEB_CAPABILITIES, SESSIOND_RUNTIME_CAPABILITIES, WEB_RUNTIME_CAPABILITIES, parseKnownPiWebCapabilities } from "./capabilities";
|
||||
|
||||
describe("PI WEB capabilities", () => {
|
||||
it("advertises web-only capabilities without requiring session daemon support", () => {
|
||||
expect(WEB_RUNTIME_CAPABILITIES).toContain(PI_WEB_CAPABILITIES.piPackagesManage);
|
||||
expect(WEB_RUNTIME_CAPABILITIES).toContain(PI_WEB_CAPABILITIES.selectedMachineSettings);
|
||||
expect(SESSIOND_RUNTIME_CAPABILITIES).not.toContain(PI_WEB_CAPABILITIES.piPackagesManage);
|
||||
expect(SESSIOND_RUNTIME_CAPABILITIES).not.toContain(PI_WEB_CAPABILITIES.selectedMachineSettings);
|
||||
|
||||
expect(effectivePiWebCapabilities({
|
||||
web: { available: true, capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings] },
|
||||
sessiond: { available: false, capabilities: [] },
|
||||
})).toEqual([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings]);
|
||||
});
|
||||
|
||||
it("requires web and session daemon support for authoritative session persistence", () => {
|
||||
expect(WEB_RUNTIME_CAPABILITIES).toContain(PI_WEB_CAPABILITIES.sessionsPersistedState);
|
||||
expect(SESSIOND_RUNTIME_CAPABILITIES).toContain(PI_WEB_CAPABILITIES.sessionsPersistedState);
|
||||
|
||||
expect(effectivePiWebCapabilities({
|
||||
web: { available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsPersistedState] },
|
||||
sessiond: { available: false, capabilities: [PI_WEB_CAPABILITIES.sessionsPersistedState] },
|
||||
})).not.toContain(PI_WEB_CAPABILITIES.sessionsPersistedState);
|
||||
expect(effectivePiWebCapabilities({
|
||||
web: { available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsPersistedState] },
|
||||
sessiond: { available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsPersistedState] },
|
||||
})).toContain(PI_WEB_CAPABILITIES.sessionsPersistedState);
|
||||
});
|
||||
|
||||
it("keeps only known string capabilities when parsing runtime data", () => {
|
||||
expect(parseKnownPiWebCapabilities([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, "future.capability"])).toEqual([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings]);
|
||||
expect(parseKnownPiWebCapabilities([PI_WEB_CAPABILITIES.piPackagesManage, 1])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -6,15 +6,37 @@ export type { PiWebCapability };
|
||||
export const KNOWN_PI_WEB_CAPABILITIES = Object.values(PI_WEB_CAPABILITIES);
|
||||
const knownPiWebCapabilities: ReadonlySet<string> = new Set(KNOWN_PI_WEB_CAPABILITIES);
|
||||
|
||||
export const WEB_RUNTIME_CAPABILITIES = [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.sessionsCleanup, PI_WEB_CAPABILITIES.sessionsReload, PI_WEB_CAPABILITIES.promptAttachments, PI_WEB_CAPABILITIES.workspaceFileSuggestions] as const satisfies readonly PiWebCapability[];
|
||||
export const SESSIOND_RUNTIME_CAPABILITIES = [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.sessionsCleanup, PI_WEB_CAPABILITIES.sessionsReload, PI_WEB_CAPABILITIES.promptAttachments] as const satisfies readonly PiWebCapability[];
|
||||
export const WEB_RUNTIME_CAPABILITIES = [
|
||||
PI_WEB_CAPABILITIES.sessionsDeleteArchived,
|
||||
PI_WEB_CAPABILITIES.sessionsBulkMutations,
|
||||
PI_WEB_CAPABILITIES.sessionsCleanup,
|
||||
PI_WEB_CAPABILITIES.sessionsReload,
|
||||
PI_WEB_CAPABILITIES.sessionsPersistedState,
|
||||
PI_WEB_CAPABILITIES.promptAttachments,
|
||||
PI_WEB_CAPABILITIES.workspaceFileSuggestions,
|
||||
PI_WEB_CAPABILITIES.piPackagesManage,
|
||||
PI_WEB_CAPABILITIES.selectedMachineSettings,
|
||||
] as const satisfies readonly PiWebCapability[];
|
||||
|
||||
export const SESSIOND_RUNTIME_CAPABILITIES = [
|
||||
PI_WEB_CAPABILITIES.sessionsDeleteArchived,
|
||||
PI_WEB_CAPABILITIES.sessionsBulkMutations,
|
||||
PI_WEB_CAPABILITIES.sessionsCleanup,
|
||||
PI_WEB_CAPABILITIES.sessionsReload,
|
||||
PI_WEB_CAPABILITIES.sessionsPersistedState,
|
||||
PI_WEB_CAPABILITIES.promptAttachments,
|
||||
] as const satisfies readonly PiWebCapability[];
|
||||
|
||||
const EFFECTIVE_CAPABILITY_REQUIREMENTS = {
|
||||
[PI_WEB_CAPABILITIES.sessionsDeleteArchived]: ["web", "sessiond"],
|
||||
[PI_WEB_CAPABILITIES.sessionsBulkMutations]: ["web", "sessiond"],
|
||||
[PI_WEB_CAPABILITIES.sessionsCleanup]: ["web", "sessiond"],
|
||||
[PI_WEB_CAPABILITIES.sessionsReload]: ["web", "sessiond"],
|
||||
[PI_WEB_CAPABILITIES.sessionsPersistedState]: ["web", "sessiond"],
|
||||
[PI_WEB_CAPABILITIES.promptAttachments]: ["web", "sessiond"],
|
||||
[PI_WEB_CAPABILITIES.workspaceFileSuggestions]: ["web"],
|
||||
[PI_WEB_CAPABILITIES.piPackagesManage]: ["web"],
|
||||
[PI_WEB_CAPABILITIES.selectedMachineSettings]: ["web"],
|
||||
} as const satisfies Record<PiWebCapability, readonly PiWebServiceComponent[]>;
|
||||
|
||||
export function isPiWebCapability(value: unknown): value is PiWebCapability {
|
||||
@@ -25,6 +47,11 @@ export function supportsPiWebCapability(source: { capabilities?: readonly PiWebC
|
||||
return source?.capabilities?.includes(capability) === true;
|
||||
}
|
||||
|
||||
export function parseKnownPiWebCapabilities(value: unknown): PiWebCapability[] | undefined {
|
||||
if (!Array.isArray(value) || !value.every((capability) => typeof capability === "string")) return undefined;
|
||||
return value.filter(isPiWebCapability);
|
||||
}
|
||||
|
||||
export function effectivePiWebCapabilities(components: Partial<Record<PiWebServiceComponent, Pick<PiWebRuntimeComponent, "available" | "capabilities">>>): PiWebCapability[] {
|
||||
return KNOWN_PI_WEB_CAPABILITIES.filter((capability) => {
|
||||
const requiredComponents = EFFECTIVE_CAPABILITY_REQUIREMENTS[capability];
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
export type FederatedHttpMethod = "GET" | "POST" | "PUT" | "DELETE";
|
||||
|
||||
export const PI_PACKAGE_MUTATION_PROXY_TIMEOUT_MS = 5 * 60_000;
|
||||
|
||||
export interface FederatedHttpRouteSpec {
|
||||
method: FederatedHttpMethod;
|
||||
path: string;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export const FEDERATED_HTTP_ROUTES = [
|
||||
{ method: "GET", path: "/pi-web/status" },
|
||||
{ method: "GET", path: "/config" },
|
||||
{ method: "PUT", path: "/config" },
|
||||
{ method: "GET", path: "/plugins" },
|
||||
{ method: "GET", path: "/pi-packages" },
|
||||
{ method: "POST", path: "/pi-packages/install", timeoutMs: PI_PACKAGE_MUTATION_PROXY_TIMEOUT_MS },
|
||||
{ method: "POST", path: "/pi-packages/remove", timeoutMs: PI_PACKAGE_MUTATION_PROXY_TIMEOUT_MS },
|
||||
{ method: "POST", path: "/pi-packages/update", timeoutMs: PI_PACKAGE_MUTATION_PROXY_TIMEOUT_MS },
|
||||
{ method: "GET", path: "/projects" },
|
||||
{ method: "POST", path: "/projects" },
|
||||
{ method: "DELETE", path: "/projects/:projectId" },
|
||||
@@ -37,6 +47,8 @@ export const FEDERATED_HTTP_ROUTES = [
|
||||
{ method: "POST", path: "/sessions" },
|
||||
{ method: "POST", path: "/sessions/cleanup/preview" },
|
||||
{ method: "POST", path: "/sessions/cleanup" },
|
||||
{ method: "POST", path: "/sessions/bulk/archive" },
|
||||
{ method: "POST", path: "/sessions/bulk/delete-archived" },
|
||||
{ method: "GET", path: "/sessions/:sessionId/messages" },
|
||||
{ method: "GET", path: "/sessions/:sessionId/status" },
|
||||
{ method: "GET", path: "/sessions/:sessionId/models" },
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PI_WEB_CAPABILITIES } from "./capabilities";
|
||||
import { parsePiWebComponentStatus, parsePiWebInstallationInfo, parsePiWebRuntimeResponse, parsePiWebVersionResponse } from "./piWebStatusParsing";
|
||||
|
||||
describe("PI WEB status parsing", () => {
|
||||
it("parses known top-level and component capabilities while ignoring unknown strings", () => {
|
||||
expect(parsePiWebRuntimeResponse({
|
||||
packageName: "@jmfederico/pi-web",
|
||||
generatedAt: "now",
|
||||
components: {
|
||||
web: { component: "web", label: "Web/UI", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, "future.capability"] },
|
||||
sessiond: { component: "sessiond", label: "Session daemon", runtimeVersion: "1.0.0", available: true, capabilities: ["future.sessiondCapability"] },
|
||||
},
|
||||
capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, "future.capability"],
|
||||
})).toMatchObject({
|
||||
components: {
|
||||
web: { capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings] },
|
||||
sessiond: { capabilities: [] },
|
||||
},
|
||||
capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects runtime responses with malformed component capability arrays", () => {
|
||||
expect(parsePiWebRuntimeResponse({
|
||||
packageName: "@jmfederico/pi-web",
|
||||
generatedAt: "now",
|
||||
components: {
|
||||
web: { component: "web", label: "Web/UI", available: true, capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, 1] },
|
||||
sessiond: { component: "sessiond", label: "Session daemon", available: true, capabilities: [] },
|
||||
},
|
||||
capabilities: [PI_WEB_CAPABILITIES.piPackagesManage],
|
||||
})).toBeUndefined();
|
||||
});
|
||||
|
||||
it("parses Docker installation metadata", () => {
|
||||
expect(parsePiWebInstallationInfo({ kind: "docker", path: "/srv/pi-web-docker", dockerMode: "runtime" })).toEqual({
|
||||
kind: "docker",
|
||||
path: "/srv/pi-web-docker",
|
||||
dockerMode: "runtime",
|
||||
});
|
||||
expect(parsePiWebInstallationInfo({ kind: "docker", path: "/workspace/pi-web", dockerMode: "dev" })).toEqual({
|
||||
kind: "docker",
|
||||
path: "/workspace/pi-web",
|
||||
dockerMode: "dev",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores invalid optional Docker modes without rejecting component status", () => {
|
||||
expect(parsePiWebComponentStatus({
|
||||
component: "web",
|
||||
label: "Web/UI",
|
||||
runtimeVersion: "1.0.0",
|
||||
stale: false,
|
||||
available: true,
|
||||
installation: { kind: "docker", path: "/workspace/pi-web", dockerMode: "hidden" },
|
||||
})?.installation).toEqual({ kind: "docker", path: "/workspace/pi-web" });
|
||||
});
|
||||
|
||||
it("parses version responses that include Docker runtime and development components", () => {
|
||||
const parsed = parsePiWebVersionResponse({
|
||||
packageName: "@jmfederico/pi-web",
|
||||
generatedAt: "now",
|
||||
components: {
|
||||
web: { component: "web", label: "Web/UI", runtimeVersion: "1.0.0", stale: false, available: true, installation: { kind: "docker", path: "/srv/pi-web-docker", dockerMode: "runtime" } },
|
||||
sessiond: { component: "sessiond", label: "Session daemon", runtimeVersion: "1.0.0", stale: false, available: true, installation: { kind: "docker", path: "/workspace/pi-web", dockerMode: "dev" } },
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed?.components.web.installation).toEqual({ kind: "docker", path: "/srv/pi-web-docker", dockerMode: "runtime" });
|
||||
expect(parsed?.components.sessiond.installation).toEqual({ kind: "docker", path: "/workspace/pi-web", dockerMode: "dev" });
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { PiWebCapability, PiWebComponentStatus, PiWebInstallationInfo, PiWebRuntimeComponent, PiWebVersionResponse } from "./apiTypes.js";
|
||||
import { isPiWebCapability } from "./capabilities.js";
|
||||
import type { PiWebComponentStatus, PiWebInstallationInfo, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebVersionResponse } from "./apiTypes.js";
|
||||
import { parseKnownPiWebCapabilities } from "./capabilities.js";
|
||||
|
||||
export function parsePiWebVersionResponse(value: unknown): PiWebVersionResponse | undefined {
|
||||
if (!isRecord(value)) return undefined;
|
||||
@@ -13,13 +13,26 @@ export function parsePiWebVersionResponse(value: unknown): PiWebVersionResponse
|
||||
return { packageName, generatedAt, components: { web, sessiond } };
|
||||
}
|
||||
|
||||
export function parsePiWebRuntimeResponse(value: unknown): PiWebRuntimeResponse | undefined {
|
||||
if (!isRecord(value)) return undefined;
|
||||
const packageName = value["packageName"];
|
||||
const generatedAt = value["generatedAt"];
|
||||
const components = value["components"];
|
||||
const capabilities = parseKnownPiWebCapabilities(value["capabilities"]);
|
||||
if (typeof packageName !== "string" || packageName === "" || typeof generatedAt !== "string" || generatedAt === "" || !isRecord(components) || capabilities === undefined) return undefined;
|
||||
const web = parsePiWebRuntimeComponent(components["web"]);
|
||||
const sessiond = parsePiWebRuntimeComponent(components["sessiond"]);
|
||||
if (web === undefined || sessiond === undefined) return undefined;
|
||||
return { packageName, generatedAt, components: { web, sessiond }, capabilities };
|
||||
}
|
||||
|
||||
export function parsePiWebRuntimeComponent(value: unknown): PiWebRuntimeComponent | undefined {
|
||||
if (!isRecord(value)) return undefined;
|
||||
const component = value["component"];
|
||||
const label = value["label"];
|
||||
const runtimeVersion = value["runtimeVersion"];
|
||||
const available = value["available"];
|
||||
const capabilities = parsePiWebCapabilities(value["capabilities"]);
|
||||
const capabilities = parseKnownPiWebCapabilities(value["capabilities"]);
|
||||
const error = value["error"];
|
||||
if (component !== "web" && component !== "sessiond") return undefined;
|
||||
if (typeof label !== "string" || label === "" || typeof available !== "boolean" || capabilities === undefined) return undefined;
|
||||
@@ -57,11 +70,6 @@ export function parsePiWebComponentStatus(value: unknown): PiWebComponentStatus
|
||||
};
|
||||
}
|
||||
|
||||
function parsePiWebCapabilities(value: unknown): PiWebCapability[] | undefined {
|
||||
if (!Array.isArray(value) || !value.every(isPiWebCapability)) return undefined;
|
||||
return value;
|
||||
}
|
||||
|
||||
export function parsePiWebInstallationInfo(value: unknown): PiWebInstallationInfo | undefined {
|
||||
if (!isRecord(value)) return undefined;
|
||||
const kind = value["kind"];
|
||||
@@ -69,13 +77,15 @@ export function parsePiWebInstallationInfo(value: unknown): PiWebInstallationInf
|
||||
const source = value["source"];
|
||||
const scope = value["scope"];
|
||||
const npmRoot = value["npmRoot"];
|
||||
if (kind !== "pi-package" && kind !== "npm-global" && kind !== "local" && kind !== "unknown") return undefined;
|
||||
const dockerMode = value["dockerMode"];
|
||||
if (kind !== "pi-package" && kind !== "npm-global" && kind !== "local" && kind !== "docker" && kind !== "unknown") return undefined;
|
||||
return {
|
||||
kind,
|
||||
...(typeof path === "string" ? { path } : {}),
|
||||
...(typeof source === "string" ? { source } : {}),
|
||||
...(scope === "user" || scope === "project" ? { scope } : {}),
|
||||
...(typeof npmRoot === "string" ? { npmRoot } : {}),
|
||||
...(dockerMode === "runtime" || dockerMode === "dev" ? { dockerMode } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { base64ByteLength, extensionForImageMimeType, isSupportedImageMimeType, MAX_INLINE_IMAGE_BASE64_BYTES, parsePromptAttachments } from "./promptAttachments.js";
|
||||
|
||||
const tinyPngBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCA',".replace(/[^A-Za-z0-9+/=]/g, "");
|
||||
const validImageBase64 = "QUJD";
|
||||
|
||||
describe("isSupportedImageMimeType", () => {
|
||||
it("accepts pi-supported image types", () => {
|
||||
@@ -43,12 +43,12 @@ describe("parsePromptAttachments", () => {
|
||||
});
|
||||
|
||||
it("normalizes valid attachments", () => {
|
||||
const result = parsePromptAttachments([{ kind: "image", mimeType: "image/png", data: tinyPngBase64, name: "shot.png" }]);
|
||||
expect(result).toEqual([{ kind: "image", mimeType: "image/png", data: tinyPngBase64, name: "shot.png" }]);
|
||||
const result = parsePromptAttachments([{ kind: "image", mimeType: "image/png", data: validImageBase64, name: "shot.png" }]);
|
||||
expect(result).toEqual([{ kind: "image", mimeType: "image/png", data: validImageBase64, name: "shot.png" }]);
|
||||
});
|
||||
|
||||
it("drops empty names", () => {
|
||||
const result = parsePromptAttachments([{ kind: "image", mimeType: "image/png", data: tinyPngBase64, name: "" }]);
|
||||
const result = parsePromptAttachments([{ kind: "image", mimeType: "image/png", data: validImageBase64, name: "" }]);
|
||||
expect(result[0]).not.toHaveProperty("name");
|
||||
});
|
||||
|
||||
@@ -57,9 +57,9 @@ describe("parsePromptAttachments", () => {
|
||||
});
|
||||
|
||||
it("rejects unsupported kinds and mime types", () => {
|
||||
expect(() => parsePromptAttachments([{ kind: "video", mimeType: "image/png", data: tinyPngBase64 }])).toThrow(/unsupported kind/);
|
||||
expect(() => parsePromptAttachments([{ kind: "file", mimeType: "application/pdf", data: tinyPngBase64 }])).toThrow(/unsupported kind/);
|
||||
expect(() => parsePromptAttachments([{ kind: "image", mimeType: "image/svg+xml", data: tinyPngBase64 }])).toThrow(/unsupported image type/);
|
||||
expect(() => parsePromptAttachments([{ kind: "video", mimeType: "image/png", data: validImageBase64 }])).toThrow(/unsupported kind/);
|
||||
expect(() => parsePromptAttachments([{ kind: "file", mimeType: "application/pdf", data: validImageBase64 }])).toThrow(/unsupported kind/);
|
||||
expect(() => parsePromptAttachments([{ kind: "image", mimeType: "image/svg+xml", data: validImageBase64 }])).toThrow(/unsupported image type/);
|
||||
});
|
||||
|
||||
it("accepts generic files only when file attachments are allowed", () => {
|
||||
@@ -83,7 +83,7 @@ describe("parsePromptAttachments", () => {
|
||||
});
|
||||
|
||||
it("keeps image MIME validation when file attachments are allowed", () => {
|
||||
expect(() => parsePromptAttachments([{ kind: "image", mimeType: "image/svg+xml", data: tinyPngBase64 }], { allowFileAttachments: true })).toThrow(/unsupported image type/);
|
||||
expect(() => parsePromptAttachments([{ kind: "image", mimeType: "image/svg+xml", data: validImageBase64 }], { allowFileAttachments: true })).toThrow(/unsupported image type/);
|
||||
});
|
||||
|
||||
it("rejects invalid base64 data", () => {
|
||||
@@ -97,7 +97,7 @@ describe("parsePromptAttachments", () => {
|
||||
});
|
||||
|
||||
it("enforces the attachment count limit", () => {
|
||||
const many = Array.from({ length: 3 }, () => ({ kind: "image", mimeType: "image/png", data: tinyPngBase64 }));
|
||||
const many = Array.from({ length: 3 }, () => ({ kind: "image", mimeType: "image/png", data: validImageBase64 }));
|
||||
expect(() => parsePromptAttachments(many, { maxAttachments: 2 })).toThrow(/too many attachments/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,14 +30,14 @@ describe("thinkingLevels", () => {
|
||||
const known = KNOWN_THINKING_LEVELS;
|
||||
|
||||
it("derives bar count from the available set (excluding the off level)", () => {
|
||||
// 6 known levels => 5 bars.
|
||||
expect(thinkingGauge("off", known).total).toBe(5);
|
||||
// 7 known levels => 6 bars.
|
||||
expect(thinkingGauge("off", known).total).toBe(6);
|
||||
expect(thinkingGauge("off", ["off", "low", "high"]).total).toBe(2);
|
||||
});
|
||||
|
||||
it("treats the first level as no thinking (0 filled)", () => {
|
||||
expect(thinkingGauge("off", known)).toEqual({ total: 5, filled: 0 });
|
||||
expect(thinkingGauge(undefined, known)).toEqual({ total: 5, filled: 0 });
|
||||
expect(thinkingGauge("off", known)).toEqual({ total: 6, filled: 0 });
|
||||
expect(thinkingGauge(undefined, known)).toEqual({ total: 6, filled: 0 });
|
||||
});
|
||||
|
||||
it("fills up to the current level's rank", () => {
|
||||
@@ -46,6 +46,7 @@ describe("thinkingLevels", () => {
|
||||
expect(thinkingGauge("medium", known).filled).toBe(3);
|
||||
expect(thinkingGauge("high", known).filled).toBe(4);
|
||||
expect(thinkingGauge("xhigh", known).filled).toBe(5);
|
||||
expect(thinkingGauge("max", known).filled).toBe(6);
|
||||
});
|
||||
|
||||
it("adapts to a runtime-provided set of a different size", () => {
|
||||
@@ -56,8 +57,8 @@ describe("thinkingLevels", () => {
|
||||
});
|
||||
|
||||
it("falls back to the known set when no usable available set is given", () => {
|
||||
expect(thinkingGauge("high", [])).toEqual({ total: 5, filled: 4 });
|
||||
expect(thinkingGauge("high", ["only-one"])).toEqual({ total: 5, filled: 4 });
|
||||
expect(thinkingGauge("high", [])).toEqual({ total: 6, filled: 4 });
|
||||
expect(thinkingGauge("high", ["only-one"])).toEqual({ total: 6, filled: 4 });
|
||||
});
|
||||
|
||||
it("fills 0 for an unknown current level instead of throwing", () => {
|
||||
|
||||
@@ -13,7 +13,7 @@ export type { ThinkingLevel };
|
||||
* either breaks, update this list and give the new level a label/description
|
||||
* where thinking levels are presented.
|
||||
*/
|
||||
export const KNOWN_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const satisfies readonly ThinkingLevel[];
|
||||
export const KNOWN_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const satisfies readonly ThinkingLevel[];
|
||||
|
||||
export function isKnownThinkingLevel(value: string): value is ThinkingLevel {
|
||||
return KNOWN_THINKING_LEVELS.some((level) => level === value);
|
||||
|
||||
Reference in New Issue
Block a user