Просмотр исходного кода

feat(interaction): add shared control models and runtime bridge

Ma 7 месяцев назад
Родитель
Сommit
41129f46e2

+ 79 - 0
packages/core/src/__tests__/interaction-models.test.ts

@@ -0,0 +1,79 @@
+import { describe, expect, it } from "vitest";
+import {
+  AutomationModeSchema,
+  InteractionIntentTypeSchema,
+  ExecutionStatusSchema,
+  InteractionSessionSchema,
+  bindActiveBook,
+  clearPendingDecision,
+  isTerminalExecutionStatus,
+} from "../index.js";
+
+describe("interaction models", () => {
+  it("parses supported automation modes", () => {
+    expect(AutomationModeSchema.parse("auto")).toBe("auto");
+    expect(AutomationModeSchema.parse("semi")).toBe("semi");
+    expect(AutomationModeSchema.parse("manual")).toBe("manual");
+  });
+
+  it("parses supported interaction intents", () => {
+    expect(InteractionIntentTypeSchema.parse("create_book")).toBe("create_book");
+    expect(InteractionIntentTypeSchema.parse("write_next")).toBe("write_next");
+    expect(InteractionIntentTypeSchema.parse("rewrite_chapter")).toBe("rewrite_chapter");
+    expect(InteractionIntentTypeSchema.parse("explain_failure")).toBe("explain_failure");
+  });
+
+  it("recognizes terminal execution statuses", () => {
+    expect(isTerminalExecutionStatus(ExecutionStatusSchema.parse("completed"))).toBe(true);
+    expect(isTerminalExecutionStatus(ExecutionStatusSchema.parse("failed"))).toBe(true);
+    expect(isTerminalExecutionStatus(ExecutionStatusSchema.parse("idle"))).toBe(false);
+    expect(isTerminalExecutionStatus(ExecutionStatusSchema.parse("writing"))).toBe(false);
+  });
+
+  it("binds the active book without disturbing unrelated session fields", () => {
+    const session = InteractionSessionSchema.parse({
+      sessionId: "session-1",
+      projectRoot: "/tmp/project",
+      automationMode: "semi",
+      messages: [],
+      pendingDecision: {
+        kind: "approve-chapter",
+        bookId: "book-a",
+        chapterNumber: 3,
+        summary: "Chapter 3 is waiting for review.",
+      },
+      currentExecution: {
+        status: "waiting_human",
+        bookId: "book-a",
+        chapterNumber: 3,
+        stageLabel: "waiting for approval",
+      },
+    });
+
+    expect(bindActiveBook(session, "book-b")).toEqual({
+      ...session,
+      activeBookId: "book-b",
+    });
+  });
+
+  it("clears pending decisions while keeping the rest of the session intact", () => {
+    const session = InteractionSessionSchema.parse({
+      sessionId: "session-2",
+      projectRoot: "/tmp/project",
+      activeBookId: "book-a",
+      automationMode: "auto",
+      messages: [],
+      pendingDecision: {
+        kind: "choose-repair-mode",
+        bookId: "book-a",
+        chapterNumber: 8,
+        summary: "Choose whether to local-fix or rewrite chapter 8.",
+      },
+    });
+
+    expect(clearPendingDecision(session)).toEqual({
+      ...session,
+      pendingDecision: undefined,
+    });
+  });
+});

+ 158 - 0
packages/core/src/__tests__/interaction-runtime.test.ts

@@ -0,0 +1,158 @@
+import { describe, expect, it, vi } from "vitest";
+import { InteractionSessionSchema } from "../interaction/session.js";
+import { runInteractionRequest } from "../interaction/runtime.js";
+
+describe("interaction runtime", () => {
+  it("routes continue/write intents to writeNextChapter", async () => {
+    const writeNextChapter = vi.fn(async () => ({
+      chapterNumber: 7,
+      title: "Harbor Ledger",
+      wordCount: 3210,
+      revised: false,
+      status: "ready-for-review" as const,
+    }));
+    const reviseDraft = vi.fn();
+    const updateCurrentFocus = vi.fn();
+    const updateAuthorIntent = vi.fn();
+
+    const session = InteractionSessionSchema.parse({
+      sessionId: "session-1",
+      projectRoot: "/tmp/project",
+      activeBookId: "harbor",
+      automationMode: "semi",
+      messages: [],
+    });
+
+    const result = await runInteractionRequest({
+      session,
+      request: { intent: "write_next", bookId: "harbor" },
+      tools: { writeNextChapter, reviseDraft, updateCurrentFocus, updateAuthorIntent },
+    });
+
+    expect(writeNextChapter).toHaveBeenCalledWith("harbor");
+    expect(reviseDraft).not.toHaveBeenCalled();
+    expect(result.session.activeBookId).toBe("harbor");
+    expect(result.session.currentExecution?.status).toBe("completed");
+  });
+
+  it("routes revise_chapter to reviseDraft with local-fix", async () => {
+    const writeNextChapter = vi.fn();
+    const reviseDraft = vi.fn(async () => ({
+      chapterNumber: 3,
+      wordCount: 2800,
+      fixedIssues: ["tightened ending"],
+      applied: true,
+      status: "ready-for-review" as const,
+    }));
+
+    const session = InteractionSessionSchema.parse({
+      sessionId: "session-2",
+      projectRoot: "/tmp/project",
+      activeBookId: "harbor",
+      automationMode: "manual",
+      messages: [],
+    });
+
+    await runInteractionRequest({
+      session,
+      request: { intent: "revise_chapter", bookId: "harbor", chapterNumber: 3 },
+      tools: {
+        writeNextChapter,
+        reviseDraft,
+        updateCurrentFocus: vi.fn(),
+        updateAuthorIntent: vi.fn(),
+      },
+    });
+
+    expect(reviseDraft).toHaveBeenCalledWith("harbor", 3, "local-fix");
+  });
+
+  it("routes rewrite_chapter to reviseDraft with rewrite mode", async () => {
+    const reviseDraft = vi.fn(async () => ({
+      chapterNumber: 5,
+      wordCount: 3100,
+      fixedIssues: ["rewrote chapter"],
+      applied: true,
+      status: "ready-for-review" as const,
+    }));
+
+    await runInteractionRequest({
+      session: InteractionSessionSchema.parse({
+        sessionId: "session-3",
+        projectRoot: "/tmp/project",
+        activeBookId: "harbor",
+        automationMode: "manual",
+        messages: [],
+      }),
+      request: { intent: "rewrite_chapter", bookId: "harbor", chapterNumber: 5 },
+      tools: {
+        writeNextChapter: vi.fn(),
+        reviseDraft,
+        updateCurrentFocus: vi.fn(),
+        updateAuthorIntent: vi.fn(),
+      },
+    });
+
+    expect(reviseDraft).toHaveBeenCalledWith("harbor", 5, "rewrite");
+  });
+
+  it("routes update_focus to the focus updater", async () => {
+    const updateCurrentFocus = vi.fn(async () => ({ ok: true }));
+
+    await runInteractionRequest({
+      session: InteractionSessionSchema.parse({
+        sessionId: "session-4",
+        projectRoot: "/tmp/project",
+        activeBookId: "harbor",
+        automationMode: "semi",
+        messages: [],
+      }),
+      request: {
+        intent: "update_focus",
+        bookId: "harbor",
+        instruction: "Bring the story back to the old harbor debt line.",
+      },
+      tools: {
+        writeNextChapter: vi.fn(),
+        reviseDraft: vi.fn(),
+        updateCurrentFocus,
+        updateAuthorIntent: vi.fn(),
+      },
+    });
+
+    expect(updateCurrentFocus).toHaveBeenCalledWith(
+      "harbor",
+      "Bring the story back to the old harbor debt line.",
+    );
+  });
+
+  it("updates automation mode without invoking pipeline tools", async () => {
+    const writeNextChapter = vi.fn();
+    const reviseDraft = vi.fn();
+    const updateCurrentFocus = vi.fn();
+    const updateAuthorIntent = vi.fn();
+
+    const result = await runInteractionRequest({
+      session: InteractionSessionSchema.parse({
+        sessionId: "session-5",
+        projectRoot: "/tmp/project",
+        activeBookId: "harbor",
+        automationMode: "semi",
+        messages: [],
+      }),
+      request: { intent: "switch_mode", mode: "auto" },
+      tools: {
+        writeNextChapter,
+        reviseDraft,
+        updateCurrentFocus,
+        updateAuthorIntent,
+      },
+    });
+
+    expect(result.session.automationMode).toBe("auto");
+    expect(writeNextChapter).not.toHaveBeenCalled();
+    expect(reviseDraft).not.toHaveBeenCalled();
+    expect(updateCurrentFocus).not.toHaveBeenCalled();
+    expect(updateAuthorIntent).not.toHaveBeenCalled();
+  });
+});

+ 35 - 0
packages/core/src/index.ts

@@ -68,6 +68,41 @@ export {
 } from "./models/input-governance.js";
 export { PlannerAgent, type PlanChapterInput, type PlanChapterOutput } from "./agents/planner.js";
 export { ComposerAgent, type ComposeChapterInput, type ComposeChapterOutput } from "./agents/composer.js";
+export {
+  AutomationModeSchema,
+  type AutomationMode,
+  normalizeAutomationMode,
+} from "./interaction/modes.js";
+export {
+  InteractionIntentTypeSchema,
+  type InteractionIntentType,
+  InteractionRequestSchema,
+  type InteractionRequest,
+} from "./interaction/intents.js";
+export {
+  ExecutionStatusSchema,
+  ExecutionStateSchema,
+  type ExecutionStatus,
+  type ExecutionState,
+  isTerminalExecutionStatus,
+} from "./interaction/events.js";
+export {
+  PendingDecisionSchema,
+  InteractionMessageSchema,
+  InteractionSessionSchema,
+  type PendingDecision,
+  type InteractionMessage,
+  type InteractionSession,
+  bindActiveBook,
+  clearPendingDecision,
+  updateAutomationMode,
+} from "./interaction/session.js";
+export { routeInteractionRequest } from "./interaction/request-router.js";
+export {
+  runInteractionRequest,
+  type InteractionRuntimeTools,
+  type InteractionRuntimeResult,
+} from "./interaction/runtime.js";
 
 // LLM
 export { createLLMClient, chatCompletion, chatWithTools, createStreamMonitor, PartialResponseError, type LLMClient, type LLMResponse, type LLMMessage, type ToolDefinition, type ToolCall, type AgentMessage, type ChatWithToolsResult, type StreamProgress, type OnStreamProgress } from "./llm/provider.js";

+ 30 - 0
packages/core/src/interaction/events.ts

@@ -0,0 +1,30 @@
+import { z } from "zod";
+
+export const ExecutionStatusSchema = z.enum([
+  "idle",
+  "planning",
+  "composing",
+  "writing",
+  "assessing",
+  "repairing",
+  "persisting",
+  "waiting_human",
+  "blocked",
+  "completed",
+  "failed",
+]);
+
+export type ExecutionStatus = z.infer<typeof ExecutionStatusSchema>;
+
+export const ExecutionStateSchema = z.object({
+  status: ExecutionStatusSchema,
+  bookId: z.string().min(1).optional(),
+  chapterNumber: z.number().int().min(1).optional(),
+  stageLabel: z.string().min(1).optional(),
+});
+
+export type ExecutionState = z.infer<typeof ExecutionStateSchema>;
+
+export function isTerminalExecutionStatus(status: ExecutionStatus): boolean {
+  return status === "completed" || status === "failed" || status === "blocked";
+}

+ 32 - 0
packages/core/src/interaction/intents.ts

@@ -0,0 +1,32 @@
+import { z } from "zod";
+import { AutomationModeSchema } from "./modes.js";
+
+export const InteractionIntentTypeSchema = z.enum([
+  "create_book",
+  "select_book",
+  "continue_book",
+  "write_next",
+  "pause_book",
+  "resume_book",
+  "revise_chapter",
+  "rewrite_chapter",
+  "edit_truth",
+  "update_focus",
+  "update_author_intent",
+  "explain_status",
+  "explain_failure",
+  "export_book",
+  "switch_mode",
+]);
+
+export type InteractionIntentType = z.infer<typeof InteractionIntentTypeSchema>;
+
+export const InteractionRequestSchema = z.object({
+  intent: InteractionIntentTypeSchema,
+  bookId: z.string().min(1).optional(),
+  chapterNumber: z.number().int().min(1).optional(),
+  instruction: z.string().min(1).optional(),
+  mode: AutomationModeSchema.optional(),
+});
+
+export type InteractionRequest = z.infer<typeof InteractionRequestSchema>;

+ 13 - 0
packages/core/src/interaction/modes.ts

@@ -0,0 +1,13 @@
+import { z } from "zod";
+
+export const AutomationModeSchema = z.enum(["auto", "semi", "manual"]);
+
+export type AutomationMode = z.infer<typeof AutomationModeSchema>;
+
+export function normalizeAutomationMode(
+  mode: string | undefined,
+  fallback: AutomationMode = "semi",
+): AutomationMode {
+  const parsed = AutomationModeSchema.safeParse(mode);
+  return parsed.success ? parsed.data : fallback;
+}

+ 5 - 0
packages/core/src/interaction/request-router.ts

@@ -0,0 +1,5 @@
+import { InteractionRequestSchema, type InteractionRequest } from "./intents.js";
+
+export function routeInteractionRequest(input: InteractionRequest): InteractionRequest {
+  return InteractionRequestSchema.parse(input);
+}

+ 98 - 0
packages/core/src/interaction/runtime.ts

@@ -0,0 +1,98 @@
+import type { AutomationMode } from "./modes.js";
+import { routeInteractionRequest } from "./request-router.js";
+import type { InteractionRequest } from "./intents.js";
+import type { InteractionSession } from "./session.js";
+import { bindActiveBook, updateAutomationMode } from "./session.js";
+
+type ReviseMode = "local-fix" | "rewrite";
+
+export interface InteractionRuntimeTools {
+  readonly writeNextChapter: (bookId: string) => Promise<unknown>;
+  readonly reviseDraft: (bookId: string, chapterNumber: number, mode: ReviseMode) => Promise<unknown>;
+  readonly updateCurrentFocus: (bookId: string, content: string) => Promise<unknown>;
+  readonly updateAuthorIntent: (bookId: string, content: string) => Promise<unknown>;
+}
+
+export interface InteractionRuntimeResult {
+  readonly session: InteractionSession;
+}
+
+export async function runInteractionRequest(params: {
+  readonly session: InteractionSession;
+  readonly request: InteractionRequest;
+  readonly tools: InteractionRuntimeTools;
+}): Promise<InteractionRuntimeResult> {
+  const request = routeInteractionRequest(params.request);
+  let session = params.session;
+
+  if (request.mode) {
+    session = updateAutomationMode(session, request.mode as AutomationMode);
+  }
+
+  const markCompleted = (nextSession: InteractionSession): InteractionSession => ({
+    ...nextSession,
+    currentExecution: {
+      status: "completed",
+      bookId: nextSession.activeBookId,
+      chapterNumber: nextSession.activeChapterNumber,
+      stageLabel: "completed",
+    },
+  });
+
+  switch (request.intent) {
+    case "write_next":
+    case "continue_book": {
+      const bookId = request.bookId ?? session.activeBookId;
+      if (!bookId) {
+        throw new Error("No active book is bound to the interaction session.");
+      }
+      await params.tools.writeNextChapter(bookId);
+      session = bindActiveBook(session, bookId);
+      return { session: markCompleted(session) };
+    }
+    case "revise_chapter":
+    case "rewrite_chapter": {
+      const bookId = request.bookId ?? session.activeBookId;
+      if (!bookId) {
+        throw new Error("No active book is bound to the interaction session.");
+      }
+      if (!request.chapterNumber) {
+        throw new Error("Chapter number is required for chapter revision.");
+      }
+      const mode: ReviseMode = request.intent === "rewrite_chapter" ? "rewrite" : "local-fix";
+      await params.tools.reviseDraft(bookId, request.chapterNumber, mode);
+      session = bindActiveBook(session, bookId, request.chapterNumber);
+      return { session: markCompleted(session) };
+    }
+    case "update_focus": {
+      const bookId = request.bookId ?? session.activeBookId;
+      if (!bookId) {
+        throw new Error("No active book is bound to the interaction session.");
+      }
+      if (!request.instruction) {
+        throw new Error("Focus update requires instruction content.");
+      }
+      await params.tools.updateCurrentFocus(bookId, request.instruction);
+      session = bindActiveBook(session, bookId);
+      return { session: markCompleted(session) };
+    }
+    case "update_author_intent": {
+      const bookId = request.bookId ?? session.activeBookId;
+      if (!bookId) {
+        throw new Error("No active book is bound to the interaction session.");
+      }
+      if (!request.instruction) {
+        throw new Error("Author intent update requires instruction content.");
+      }
+      await params.tools.updateAuthorIntent(bookId, request.instruction);
+      session = bindActiveBook(session, bookId);
+      return { session: markCompleted(session) };
+    }
+    case "switch_mode":
+      return {
+        session: markCompleted(session),
+      };
+    default:
+      throw new Error(`Intent "${request.intent}" is not implemented in the interaction runtime yet.`);
+  }
+}

+ 66 - 0
packages/core/src/interaction/session.ts

@@ -0,0 +1,66 @@
+import { z } from "zod";
+import { AutomationModeSchema, type AutomationMode } from "./modes.js";
+import { ExecutionStateSchema } from "./events.js";
+
+export const PendingDecisionSchema = z.object({
+  kind: z.string().min(1),
+  bookId: z.string().min(1),
+  chapterNumber: z.number().int().min(1).optional(),
+  summary: z.string().min(1),
+});
+
+export type PendingDecision = z.infer<typeof PendingDecisionSchema>;
+
+export const InteractionMessageSchema = z.object({
+  role: z.enum(["user", "assistant", "system"]),
+  content: z.string().min(1),
+  timestamp: z.number().int().nonnegative(),
+});
+
+export type InteractionMessage = z.infer<typeof InteractionMessageSchema>;
+
+export const InteractionSessionSchema = z.object({
+  sessionId: z.string().min(1),
+  projectRoot: z.string().min(1),
+  activeBookId: z.string().min(1).optional(),
+  activeChapterNumber: z.number().int().min(1).optional(),
+  automationMode: AutomationModeSchema.default("semi"),
+  messages: z.array(InteractionMessageSchema).default([]),
+  pendingDecision: PendingDecisionSchema.optional(),
+  currentExecution: ExecutionStateSchema.optional(),
+});
+
+export type InteractionSession = z.infer<typeof InteractionSessionSchema>;
+
+export function bindActiveBook(
+  session: InteractionSession,
+  bookId: string,
+  chapterNumber?: number,
+): InteractionSession {
+  return {
+    ...session,
+    activeBookId: bookId,
+    ...(chapterNumber !== undefined ? { activeChapterNumber: chapterNumber } : {}),
+  };
+}
+
+export function clearPendingDecision(session: InteractionSession): InteractionSession {
+  if (!session.pendingDecision) {
+    return session;
+  }
+
+  return {
+    ...session,
+    pendingDecision: undefined,
+  };
+}
+
+export function updateAutomationMode(
+  session: InteractionSession,
+  automationMode: AutomationMode,
+): InteractionSession {
+  return {
+    ...session,
+    automationMode,
+  };
+}