Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions docs/session-profile-format.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Session profile record format

`lib/session-profile-persistence.ts` defines the optional session profile v1
record format and its decoder. Adding this module does not change Enter,
startup, routing, shared defaults, or live orchestrator behavior.

## Payloads

A custom entry uses `customType: "gentle-pi.session-profile/v1"` and one payload:

```json
{"kind":"bind","origin":"user","name":"work","modelProfiles":{"worker":{"model":"provider/model"},"orchestrator":{"thinking":"high"}}}
{"kind":"clear"}
```

The decoder accepts the closed origin set `user`, `local`, `repo`, and
`global`; this does not implement inherited startup persistence or follow
mode. A bound empty snapshot is not a clear. Snapshots are detached, known
invalid fields reject the whole binding, and unknown extra fields carry no
routing meaning. Existing route normalization supports legacy model strings
and `effort`; valid `thinking` takes precedence.

## Decoding one entry

`readSessionProfileEntry` classifies one supplied entry. It neither reads disk
nor establishes that the entry was persisted:

| Result | Meaning |
| --- | --- |
| `absent` | Not a profile-family custom entry. |
| `bound` | A detached, validated binding, including an empty snapshot. |
| `cleared` | An explicit clear; fallback is a later consumer's decision. |
| `invalid` | Invalid v1 data. |
| `unsupported` | Unknown profile-family identifier, regardless of payload. |

The encoder and the active-branch replay rule are a subsequent slice.
7 changes: 6 additions & 1 deletion lib/model-routing-authority.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ export type ModelConfigFileResult =
export const SAFE_MODEL_ID_PATTERN = /^[A-Za-z0-9._~:@/+%-]+$/;
const SAFE_AGENT_NAME_PATTERN = /^[A-Za-z0-9._:@/+%-]+$/;

/** Shared agent-name policy; the same test normalizeModelConfig applies per key. */
export function isSafeAgentName(name: string): boolean {
return SAFE_AGENT_NAME_PATTERN.test(name);
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
Expand Down Expand Up @@ -78,7 +83,7 @@ export function normalizeModelConfig(value: unknown): AgentModelConfig | undefin
if (!isRecord(value)) return undefined;
const cleaned: AgentModelConfig = {};
for (const [name, entryValue] of Object.entries(value)) {
if (!SAFE_AGENT_NAME_PATTERN.test(name)) continue;
if (!isSafeAgentName(name)) continue;
const entry = normalizeRoutingEntry(entryValue);
if (entry) cleaned[name] = entry;
}
Expand Down
129 changes: 129 additions & 0 deletions lib/session-profile-persistence.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// Standalone v1 codec decoder (gentle-shell#1064).
// No Pi API, disk access, shared binding store, or orchestrator application.
import { isValidProfileName } from "./agent-profiles.ts";
import {
isSafeAgentName,
isThinkingLevel,
normalizeModelId,
normalizeRoutingEntry,
type AgentModelConfig,
type AgentRoutingEntry,
} from "./model-routing-authority.ts";

export const SESSION_PROFILE_CUSTOM_TYPE = "gentle-pi.session-profile/v1";
const SESSION_PROFILE_FAMILY = "gentle-pi.session-profile/";

export type SessionProfileOrigin = "user" | "local" | "repo" | "global";

export interface SessionProfileBindPayload {
kind: "bind";
origin: SessionProfileOrigin;
name: string;
modelProfiles: AgentModelConfig;
}

export interface SessionProfileClearPayload {
kind: "clear";
}

/** Public structural entry shape only; ordinary messages need no custom fields. */
export interface SessionProfileEntry {
type: string;
customType?: string;
data?: unknown;
}

export type SessionProfileReadResult =
| { status: "absent" }
| { status: "bound"; binding: SessionProfileBindPayload }
| { status: "cleared" }
| { status: "invalid" }
| { status: "unsupported" };

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

function hasOwn(value: Record<string, unknown>, key: string): boolean {
return Object.hasOwn(value, key);
}

function isOrigin(value: unknown): value is SessionProfileOrigin {
return (
value === "user" ||
value === "local" ||
value === "repo" ||
value === "global"
);
}

/** Validate known fields before the permissive routing helper can drop them. */
function readRoute(value: unknown): AgentRoutingEntry | undefined {
if (typeof value === "string") return normalizeRoutingEntry(value);
if (!isRecord(value)) return undefined;
const route = value;
if (hasOwn(route, "model") && normalizeModelId(route.model) === undefined)
return undefined;
if (hasOwn(route, "thinking") && !isThinkingLevel(route.thinking))
return undefined;
if (hasOwn(route, "effort") && !isThinkingLevel(route.effort))
return undefined;
// Project only own known fields. Unknown extras, even an unknown-only route,
// carry no routing meaning. Thinking wins over legacy effort as elsewhere.
const known = Object.fromEntries(
["model", "thinking", "effort"]
.filter((key) => hasOwn(route, key))
.map((key) => [key, route[key]]),
);
const normalized = normalizeRoutingEntry(known);
if (!normalized) return undefined;
return Object.fromEntries(
Object.entries(normalized).filter(([, field]) => field !== undefined),
);
}

function readSnapshot(value: unknown): AgentModelConfig | undefined {
if (!isRecord(value)) return undefined;
const entries: Array<[string, AgentRoutingEntry]> = [];
for (const [name, rawRoute] of Object.entries(value)) {
// Shared agent-name policy, applied here without the forgiving whole-config
// normalization that would silently discard a bad route. Output below uses
// own data properties, never prototype setters.
if (!isSafeAgentName(name)) return undefined;
const route = readRoute(rawRoute);
if (route === undefined) return undefined;
entries.push([name, route]);
}
return Object.fromEntries(entries);
}

function readBind(
data: Record<string, unknown>,
): SessionProfileBindPayload | undefined {
if (!["origin", "name", "modelProfiles"].every((key) => hasOwn(data, key)))
return undefined;
if (!isOrigin(data.origin) || !isValidProfileName(data.name)) return undefined;
const modelProfiles = readSnapshot(data.modelProfiles);
if (modelProfiles === undefined) return undefined;
return { kind: "bind", origin: data.origin, name: data.name, modelProfiles };
}

/** Unknown family identifiers are terminal unsupported states, regardless of data. */
export function readSessionProfileEntry(
entry: SessionProfileEntry,
): SessionProfileReadResult {
if (
entry.type !== "custom" ||
typeof entry.customType !== "string" ||
!entry.customType.startsWith(SESSION_PROFILE_FAMILY)
)
return { status: "absent" };
if (entry.customType !== SESSION_PROFILE_CUSTOM_TYPE)
return { status: "unsupported" };
const data = entry.data;
if (!isRecord(data) || !hasOwn(data, "kind")) return { status: "invalid" };
if (data.kind === "clear") return { status: "cleared" };
if (data.kind !== "bind") return { status: "invalid" };
const binding = readBind(data);
return binding ? { status: "bound", binding } : { status: "invalid" };
}
207 changes: 207 additions & 0 deletions tests/session-profile-persistence.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
SESSION_PROFILE_CUSTOM_TYPE,
readSessionProfileEntry,
} from "../lib/session-profile-persistence.ts";

const custom = (data: unknown, customType = SESSION_PROFILE_CUSTOM_TYPE) => ({
type: "custom",
customType,
data,
});
const bind = (modelProfiles: unknown = {}) => ({
kind: "bind",
origin: "user",
name: "work",
modelProfiles,
});

// These are supplied entries, not evidence of persistence on disk.
test("custom type identifies the v1 session profile family", () => {
assert.equal(SESSION_PROFILE_CUSTOM_TYPE, "gentle-pi.session-profile/v1");
});

test("decoder stays strict about present undefined known fields", () => {
for (const route of [
{ model: undefined },
{ thinking: undefined },
{ effort: undefined },
]) {
assert.deepEqual(readSessionProfileEntry(custom(bind({ worker: route }))), {
status: "invalid",
});
}
});

test("bound empty snapshot is distinct from cleared", () => {
assert.deepEqual(readSessionProfileEntry(custom(bind())), {
status: "bound",
binding: bind(),
});
assert.deepEqual(readSessionProfileEntry(custom({ kind: "clear" })), {
status: "cleared",
});
});

test("reader accepts all four origins and ignores unknown payload fields", () => {
for (const origin of ["user", "local", "repo", "global"]) {
assert.deepEqual(
readSessionProfileEntry(
custom({ ...bind(), origin, extra: true, version: 99 }),
),
{
status: "bound",
binding: { ...bind(), origin },
},
);
}
assert.deepEqual(
readSessionProfileEntry(custom({ kind: "clear", extra: true })),
{ status: "cleared" },
);
});

test("legacy strings and effort normalize while orchestrator remains in the snapshot", () => {
const result = readSessionProfileEntry(
custom(
bind({
worker: " provider/model ",
reviewer: { effort: "high", ignored: true },
orchestrator: { model: "provider/main", thinking: "low", effort: "max" },
inheriting: {},
}),
),
);
assert.deepEqual(result, {
status: "bound",
binding: bind({
worker: { model: "provider/model" },
reviewer: { thinking: "high" },
orchestrator: { model: "provider/main", thinking: "low" },
inheriting: {},
}),
});
});

test("missing required fields and invalid known meanings fail closed", () => {
const invalid: unknown[] = [
null,
[],
{},
{ kind: "other" },
{ kind: "bind", name: "work", modelProfiles: {} },
{ ...bind(), origin: "future" },
{ ...bind(), name: "../work" },
{ ...bind(), name: "constructor" },
{ ...bind(), modelProfiles: undefined },
{ ...bind(), modelProfiles: [] },
];
for (const data of invalid) {
assert.deepEqual(readSessionProfileEntry(custom(data)), {
status: "invalid",
});
}
});

test("one invalid route invalidates the whole snapshot instead of silently dropping it", () => {
const routes: unknown[] = [
null,
[],
3,
"bad model",
{ model: "bad model", thinking: "low" },
{ model: "provider/good", thinking: "extreme" },
{ effort: "extreme" },
{ thinking: "low", effort: "extreme" },
{ model: null },
];
for (const route of routes) {
assert.deepEqual(readSessionProfileEntry(custom(bind({ worker: route }))), {
status: "invalid",
});
assert.deepEqual(
readSessionProfileEntry(
custom(bind({ good: "provider/good", worker: route })),
),
{ status: "invalid" },
);
}
assert.deepEqual(readSessionProfileEntry(custom(bind({ "bad role": {} }))), {
status: "invalid",
});
});

test("unknown route fields are ignored, including an unknown-only route", () => {
assert.deepEqual(
readSessionProfileEntry(custom(bind({ worker: { future: true } }))),
{
status: "bound",
binding: bind({ worker: {} }),
},
);
});

test("hostile record keys remain own data without changing prototypes", () => {
const routes = JSON.parse(
'{"__proto__":{"model":"provider/safe"},"constructor":{},"prototype":{}}',
);
const result = readSessionProfileEntry(custom(bind(routes)));
assert.equal(result.status, "bound");
if (result.status !== "bound") return;
assert.equal(
Object.getPrototypeOf(result.binding.modelProfiles),
Object.prototype,
);
assert.equal(Object.hasOwn(result.binding.modelProfiles, "__proto__"), true);
assert.deepEqual(result.binding.modelProfiles["__proto__"], {
model: "provider/safe",
});
for (const key of ["constructor", "prototype"]) {
assert.equal(Object.hasOwn(result.binding.modelProfiles, key), true);
assert.deepEqual(result.binding.modelProfiles[key], {});
}
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

test("required fields cannot be supplied by prototypes", () => {
assert.deepEqual(readSessionProfileEntry(custom(Object.create(bind()))), {
status: "invalid",
});
});

test("reader returns defensive snapshots", () => {
const entry = custom(bind({ worker: { model: "provider/first" } }));
const first = readSessionProfileEntry(entry);
assert.equal(first.status, "bound");
if (first.status !== "bound") return;
first.binding.modelProfiles.worker.model = "provider/third";
const second = readSessionProfileEntry(entry);
assert.equal(second.status, "bound");
if (second.status === "bound")
assert.equal(second.binding.modelProfiles.worker.model, "provider/first");
});

test("noncustom and unrelated custom entries are absent even with matching payloads", () => {
for (const entry of [
{ type: "message", customType: SESSION_PROFILE_CUSTOM_TYPE, data: bind() },
{ type: "message", message: { role: "user", content: "hello" } },
custom(bind(), "other/v1"),
])
assert.deepEqual(readSessionProfileEntry(entry), { status: "absent" });
});

test("family identifier, not payload version, determines support", () => {
for (const customType of [
"gentle-pi.session-profile/v0",
"gentle-pi.session-profile/v99",
"gentle-pi.session-profile/future",
]) {
assert.deepEqual(readSessionProfileEntry(custom(null, customType)), {
status: "unsupported",
});
}
assert.deepEqual(
readSessionProfileEntry(custom(bind(), "gentle-pi.session-profile-other/v1")),
{ status: "absent" },
);
});
Loading