session kind: ChatRecord.kind teach|practice (absent = teach)
Practice sessions (owner, 2026-10-04): the marker rides the record and
the normalized summary (absent field reads as teach — pre-practice
records never migrate); POST /api/chats accepts {kind: 'practice'}.
The agent branch, tools, and UI land in the following slices.
This commit is contained in:
@@ -17,9 +17,12 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
const body = (await req.json().catch(() => ({}))) as { title?: unknown };
|
const body = (await req.json().catch(() => ({}))) as { title?: unknown; kind?: unknown };
|
||||||
const title = typeof body.title === "string" ? body.title : "";
|
const title = typeof body.title === "string" ? body.title : "";
|
||||||
const chat = createChat(titleFromPrompt(title || "Untitled session"));
|
// Practice sessions (owner, 2026-10-04): kind "practice" marks the
|
||||||
|
// record — the practice agent + its tools from turn 1, no extraction.
|
||||||
|
const kind = body.kind === "practice" ? ("practice" as const) : undefined;
|
||||||
|
const chat = createChat(titleFromPrompt(title || "Untitled session"), kind);
|
||||||
// PER-SESSION MODELS (owner, 2026-09-28): a session owns its model +
|
// PER-SESSION MODELS (owner, 2026-09-28): a session owns its model +
|
||||||
// thinking level from birth — stamped verbatim from the STRONG slot,
|
// thinking level from birth — stamped verbatim from the STRONG slot,
|
||||||
// the new-session default. From here the strong slot never reaches
|
// the new-session default. From here the strong slot never reaches
|
||||||
|
|||||||
@@ -21,12 +21,20 @@ import type { EmissionStats } from "./graph.ts";
|
|||||||
* is the client's own grouping fold (core/view.ts, Wave D) — a pending quiz
|
* is the client's own grouping fold (core/view.ts, Wave D) — a pending quiz
|
||||||
* is an unanswered question tool call, which is exactly what makes
|
* is an unanswered question tool call, which is exactly what makes
|
||||||
* resume-mid-question free. */
|
* resume-mid-question free. */
|
||||||
|
/** The session's kind (practice sessions, owner 2026-10-04): "teach" is
|
||||||
|
* the default and ABSENT on pre-practice records (no migration — absence
|
||||||
|
* reads as teach); "practice" marks an adaptive-practice session — the
|
||||||
|
* practice agent, its two tools, no extraction, no graph writes. */
|
||||||
|
export type ChatKind = "teach" | "practice";
|
||||||
|
|
||||||
export interface ChatRecord {
|
export interface ChatRecord {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
messages: LhMessage[];
|
messages: LhMessage[];
|
||||||
|
/** The session's kind — absent = teach (pre-practice records). */
|
||||||
|
kind?: ChatKind;
|
||||||
/** The session's plan tree (vision §2) — written by plan tools via saveTree. */
|
/** The session's plan tree (vision §2) — written by plan tools via saveTree. */
|
||||||
tree?: PlanTree;
|
tree?: PlanTree;
|
||||||
/** Append-only usage log (observability, 2026-09): one entry per model
|
/** Append-only usage log (observability, 2026-09): one entry per model
|
||||||
@@ -202,6 +210,8 @@ export interface ChatSummary {
|
|||||||
title: string;
|
title: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
/** Normalized kind (absent record field reads as teach). */
|
||||||
|
kind: ChatKind;
|
||||||
messageCount: number;
|
messageCount: number;
|
||||||
/** The session's current phase, derived from the stored plan tree. */
|
/** The session's current phase, derived from the stored plan tree. */
|
||||||
phase: Phase3;
|
phase: Phase3;
|
||||||
|
|||||||
+3
-1
@@ -32,7 +32,9 @@ import type { UserSettings } from "./settings.ts";
|
|||||||
|
|
||||||
export interface ChatStore {
|
export interface ChatStore {
|
||||||
/** Create a session record (persisted immediately). */
|
/** Create a session record (persisted immediately). */
|
||||||
createChat(title: string): ChatRecord;
|
/** Create a session record; kind "practice" marks a practice session
|
||||||
|
* (absent = teach, the default). */
|
||||||
|
createChat(title: string, kind?: "practice"): ChatRecord;
|
||||||
/** The full record, or null if unknown id / corrupt file. */
|
/** The full record, or null if unknown id / corrupt file. */
|
||||||
getChat(id: string): ChatRecord | null;
|
getChat(id: string): ChatRecord | null;
|
||||||
/** All sessions, newest activity first (the sidebar's list view). */
|
/** All sessions, newest activity first (the sidebar's list view). */
|
||||||
|
|||||||
+6
-1
@@ -31,7 +31,7 @@ function chatFile(id: string): string {
|
|||||||
* phase, busy, pendingQuestion) is already announced by turn-started /
|
* phase, busy, pendingQuestion) is already announced by turn-started /
|
||||||
* turn-settled, which the sidebar refetches on. Silence here is law,
|
* turn-settled, which the sidebar refetches on. Silence here is law,
|
||||||
* not oversight. */
|
* not oversight. */
|
||||||
function createChat(title: string): ChatRecord {
|
function createChat(title: string, kind?: "practice"): ChatRecord {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const record: ChatRecord = {
|
const record: ChatRecord = {
|
||||||
id: `chat_${Date.now().toString(36)}_${crypto.randomUUID().slice(0, 8)}`,
|
id: `chat_${Date.now().toString(36)}_${crypto.randomUUID().slice(0, 8)}`,
|
||||||
@@ -39,6 +39,9 @@ function createChat(title: string): ChatRecord {
|
|||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
messages: [],
|
messages: [],
|
||||||
|
// Practice sessions carry the marker; teach stays ABSENT (absence
|
||||||
|
// reads as teach — pre-practice records never migrate).
|
||||||
|
...(kind === "practice" ? { kind: "practice" as const } : {}),
|
||||||
};
|
};
|
||||||
fs.mkdirSync(PATHS.chats, { recursive: true });
|
fs.mkdirSync(PATHS.chats, { recursive: true });
|
||||||
fs.writeFileSync(chatFile(record.id), JSON.stringify(record, null, "\t"), "utf-8");
|
fs.writeFileSync(chatFile(record.id), JSON.stringify(record, null, "\t"), "utf-8");
|
||||||
@@ -73,6 +76,8 @@ function listChats(): ChatSummary[] {
|
|||||||
// summaries; usage aggregates ride the full record, the picker
|
// summaries; usage aggregates ride the full record, the picker
|
||||||
// never needs them).
|
// never needs them).
|
||||||
...summary,
|
...summary,
|
||||||
|
// Normalized kind: absent record field reads as teach.
|
||||||
|
kind: summary.kind ?? "teach",
|
||||||
messageCount: messages.length,
|
messageCount: messages.length,
|
||||||
// Record-derived status for the session picker: phase from the
|
// Record-derived status for the session picker: phase from the
|
||||||
// transcript's explicit events (probe_done concluded / verdict
|
// transcript's explicit events (probe_done concluded / verdict
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* SESSION-KIND PINS (practice sessions, owner 2026-10-04): ChatRecord.kind
|
||||||
|
* is ABSENT-BY-DEFAULT — absence reads as teach, so pre-practice records
|
||||||
|
* never migrate; "practice" marks an adaptive-practice session (the
|
||||||
|
* practice agent + its two tools from turn 1, no extraction, no graph
|
||||||
|
* writes). The store normalizes at the summary projection; createChat
|
||||||
|
* stamps the marker only for practice. Runs under an isolated LH_DATA_DIR
|
||||||
|
* (the chat-birth-stamp discipline: the seam is set before any
|
||||||
|
* config-loading import, so every app-module import is dynamic).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import * as os from "node:os";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import { after, describe, it } from "node:test";
|
||||||
|
|
||||||
|
// ── the seam, BEFORE any config-loading import ──────────────────────────────
|
||||||
|
const TMP_DATA = fs.mkdtempSync(path.join(os.tmpdir(), "lh-chat-kind-"));
|
||||||
|
process.env.LH_DATA_DIR = TMP_DATA;
|
||||||
|
|
||||||
|
const { chatStore } = await import("../src/store/chats.ts");
|
||||||
|
|
||||||
|
after(() => {
|
||||||
|
fs.rmSync(TMP_DATA, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("session kind (practice sessions, 2026-10-04)", () => {
|
||||||
|
it("createChat default carries NO kind field — absence reads as teach", () => {
|
||||||
|
const chat = chatStore.createChat("plain session");
|
||||||
|
assert.equal("kind" in chat, false, "teach records carry no kind marker");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("createChat(kind: 'practice') stamps the marker on record + list", () => {
|
||||||
|
const chat = chatStore.createChat("practice session", "practice");
|
||||||
|
assert.equal(chat.kind, "practice");
|
||||||
|
const listed = chatStore.listChats().find((c) => c.id === chat.id);
|
||||||
|
assert.equal(listed?.kind, "practice");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the summary projection NORMALIZES: absent record field reads as teach", () => {
|
||||||
|
const chat = chatStore.createChat("another plain one");
|
||||||
|
const listed = chatStore.listChats().find((c) => c.id === chat.id);
|
||||||
|
assert.equal(listed?.kind, "teach");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,6 +21,7 @@ function item(overrides: Partial<ChatListItem>): ChatListItem {
|
|||||||
return {
|
return {
|
||||||
id: "c1",
|
id: "c1",
|
||||||
title: "Test session",
|
title: "Test session",
|
||||||
|
kind: "teach",
|
||||||
createdAt: "2026-01-01T00:00:00.000Z",
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
updatedAt: "2026-01-02T00:00:00.000Z",
|
updatedAt: "2026-01-02T00:00:00.000Z",
|
||||||
messageCount: 4,
|
messageCount: 4,
|
||||||
|
|||||||
Reference in New Issue
Block a user