Jelajahi Sumber

feat(interaction): track execution events and explain status

Ma 7 bulan lalu
induk
melakukan
34e1c2c713

+ 3 - 0
packages/cli/src/__tests__/tui-layout.test.ts

@@ -15,6 +15,7 @@ describe("tui layout", () => {
     expect(frame).toContain("Mode: semi");
     expect(frame).toContain("Mode: semi");
     expect(frame).toContain("Stage: idle");
     expect(frame).toContain("Stage: idle");
     expect(frame).toContain("Messages:");
     expect(frame).toContain("Messages:");
+    expect(frame).toContain("Events:");
     expect(frame).toContain(">");
     expect(frame).toContain(">");
   });
   });
 
 
@@ -25,11 +26,13 @@ describe("tui layout", () => {
       automationMode: "auto",
       automationMode: "auto",
       status: "writing",
       status: "writing",
       messages: ["user: continue", "assistant: Completed write_next for harbor."],
       messages: ["user: continue", "assistant: Completed write_next for harbor."],
+      events: ["task.completed: Completed write_next for harbor."],
     });
     });
 
 
     expect(frame).toContain("Book: Night Harbor Echo");
     expect(frame).toContain("Book: Night Harbor Echo");
     expect(frame).toContain("Mode: auto");
     expect(frame).toContain("Mode: auto");
     expect(frame).toContain("Stage: writing");
     expect(frame).toContain("Stage: writing");
     expect(frame).toContain("user: continue");
     expect(frame).toContain("user: continue");
+    expect(frame).toContain("task.completed: Completed write_next for harbor.");
   });
   });
 });
 });

+ 8 - 0
packages/cli/src/__tests__/tui-output.test.ts

@@ -17,4 +17,12 @@ describe("tui output", () => {
       mode: "auto",
       mode: "auto",
     })).toContain("Switched mode to auto");
     })).toContain("Switched mode to auto");
   });
   });
+
+  it("prefers explicit response text when provided", () => {
+    expect(formatTuiResult({
+      intent: "explain_status",
+      status: "completed",
+      responseText: "Current status: harbor is at repairing chapter 3.",
+    })).toBe("Current status: harbor is at repairing chapter 3.");
+  });
 });
 });

+ 8 - 0
packages/cli/src/tui/app.ts

@@ -21,6 +21,7 @@ export interface TuiFrameState {
   readonly automationMode: AutomationMode;
   readonly automationMode: AutomationMode;
   readonly status: ExecutionStatus;
   readonly status: ExecutionStatus;
   readonly messages?: ReadonlyArray<string>;
   readonly messages?: ReadonlyArray<string>;
+  readonly events?: ReadonlyArray<string>;
 }
 }
 
 
 export function renderTuiFrame(state: TuiFrameState): string {
 export function renderTuiFrame(state: TuiFrameState): string {
@@ -35,6 +36,11 @@ export function renderTuiFrame(state: TuiFrameState): string {
       ? state.messages.slice(-3).map((message) => `- ${message}`)
       ? state.messages.slice(-3).map((message) => `- ${message}`)
       : ["- (empty)"]),
       : ["- (empty)"]),
     "",
     "",
+    "Events:",
+    ...(state.events?.length
+      ? state.events.slice(-3).map((event) => `- ${event}`)
+      : ["- (empty)"]),
+    "",
     "> ",
     "> ",
   ];
   ];
 
 
@@ -56,6 +62,7 @@ export async function processTuiInput(
     status: result.session.currentExecution?.status ?? "completed",
     status: result.session.currentExecution?.status ?? "completed",
     bookId: result.session.activeBookId,
     bookId: result.session.activeBookId,
     mode: result.request.mode,
     mode: result.request.mode,
+    responseText: result.responseText,
   });
   });
   const nextSession = appendInteractionMessage(result.session, {
   const nextSession = appendInteractionMessage(result.session, {
     role: "assistant",
     role: "assistant",
@@ -78,6 +85,7 @@ export async function launchTui(
     automationMode: session.automationMode,
     automationMode: session.automationMode,
     status: session.currentExecution?.status ?? "idle",
     status: session.currentExecution?.status ?? "idle",
     messages: session.messages.map((message) => `${message.role}: ${message.content}`),
     messages: session.messages.map((message) => `${message.role}: ${message.content}`),
+    events: session.events.map((event) => `${event.kind}: ${event.detail ?? event.status}`),
   });
   });
 
 
   process.stdout.write(frame);
   process.stdout.write(frame);

+ 5 - 0
packages/cli/src/tui/output.ts

@@ -5,7 +5,12 @@ export function formatTuiResult(params: {
   readonly status: ExecutionStatus;
   readonly status: ExecutionStatus;
   readonly bookId?: string;
   readonly bookId?: string;
   readonly mode?: AutomationMode;
   readonly mode?: AutomationMode;
+  readonly responseText?: string;
 }): string {
 }): string {
+  if (params.responseText?.trim()) {
+    return params.responseText.trim();
+  }
+
   if (params.intent === "switch_mode" && params.mode) {
   if (params.intent === "switch_mode" && params.mode) {
     return `Switched mode to ${params.mode}.`;
     return `Switched mode to ${params.mode}.`;
   }
   }

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

@@ -8,6 +8,7 @@ import {
   clearPendingDecision,
   clearPendingDecision,
   isTerminalExecutionStatus,
   isTerminalExecutionStatus,
   appendInteractionMessage,
   appendInteractionMessage,
+  appendInteractionEvent,
 } from "../index.js";
 } from "../index.js";
 
 
 describe("interaction models", () => {
 describe("interaction models", () => {
@@ -98,4 +99,30 @@ describe("interaction models", () => {
       timestamp: 1,
       timestamp: 1,
     }]);
     }]);
   });
   });
+
+  it("appends interaction events in timestamp order", () => {
+    const session = InteractionSessionSchema.parse({
+      sessionId: "session-4",
+      projectRoot: "/tmp/project",
+      automationMode: "semi",
+      messages: [],
+      events: [],
+    });
+
+    const next = appendInteractionEvent(session, {
+      kind: "task.completed",
+      timestamp: 2,
+      status: "completed",
+      bookId: "harbor",
+      detail: "Completed write_next for harbor.",
+    });
+
+    expect(next.events).toEqual([{
+      kind: "task.completed",
+      timestamp: 2,
+      status: "completed",
+      bookId: "harbor",
+      detail: "Completed write_next for harbor.",
+    }]);
+  });
 });
 });

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

@@ -155,4 +155,57 @@ describe("interaction runtime", () => {
     expect(updateCurrentFocus).not.toHaveBeenCalled();
     expect(updateCurrentFocus).not.toHaveBeenCalled();
     expect(updateAuthorIntent).not.toHaveBeenCalled();
     expect(updateAuthorIntent).not.toHaveBeenCalled();
   });
   });
+
+  it("pauses the active book without invoking pipeline tools", async () => {
+    const result = await runInteractionRequest({
+      session: InteractionSessionSchema.parse({
+        sessionId: "session-6",
+        projectRoot: "/tmp/project",
+        activeBookId: "harbor",
+        automationMode: "auto",
+        messages: [],
+        events: [],
+      }),
+      request: { intent: "pause_book", bookId: "harbor" },
+      tools: {
+        writeNextChapter: vi.fn(),
+        reviseDraft: vi.fn(),
+        updateCurrentFocus: vi.fn(),
+        updateAuthorIntent: vi.fn(),
+      },
+    });
+
+    expect(result.session.currentExecution?.status).toBe("blocked");
+    expect(result.responseText).toContain("Paused");
+    expect(result.session.events.at(-1)?.kind).toBe("task.completed");
+  });
+
+  it("returns a human-readable explanation for explain_status", async () => {
+    const result = await runInteractionRequest({
+      session: InteractionSessionSchema.parse({
+        sessionId: "session-7",
+        projectRoot: "/tmp/project",
+        activeBookId: "harbor",
+        automationMode: "semi",
+        messages: [],
+        events: [],
+        currentExecution: {
+          status: "repairing",
+          bookId: "harbor",
+          chapterNumber: 3,
+          stageLabel: "repairing chapter 3",
+        },
+      }),
+      request: { intent: "explain_status", bookId: "harbor", instruction: "what are you doing?" },
+      tools: {
+        writeNextChapter: vi.fn(),
+        reviseDraft: vi.fn(),
+        updateCurrentFocus: vi.fn(),
+        updateAuthorIntent: vi.fn(),
+      },
+    });
+
+    expect(result.responseText).toContain("repairing");
+    expect(result.responseText).toContain("harbor");
+  });
 });
 });

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

@@ -82,8 +82,10 @@ export {
 export {
 export {
   ExecutionStatusSchema,
   ExecutionStatusSchema,
   ExecutionStateSchema,
   ExecutionStateSchema,
+  InteractionEventSchema,
   type ExecutionStatus,
   type ExecutionStatus,
   type ExecutionState,
   type ExecutionState,
+  type InteractionEvent,
   isTerminalExecutionStatus,
   isTerminalExecutionStatus,
 } from "./interaction/events.js";
 } from "./interaction/events.js";
 export {
 export {
@@ -97,6 +99,7 @@ export {
   clearPendingDecision,
   clearPendingDecision,
   updateAutomationMode,
   updateAutomationMode,
   appendInteractionMessage,
   appendInteractionMessage,
+  appendInteractionEvent,
 } from "./interaction/session.js";
 } from "./interaction/session.js";
 export {
 export {
   resolveProjectSessionPath,
   resolveProjectSessionPath,

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

@@ -25,6 +25,17 @@ export const ExecutionStateSchema = z.object({
 
 
 export type ExecutionState = z.infer<typeof ExecutionStateSchema>;
 export type ExecutionState = z.infer<typeof ExecutionStateSchema>;
 
 
+export const InteractionEventSchema = z.object({
+  kind: z.string().min(1),
+  timestamp: z.number().int().nonnegative(),
+  status: ExecutionStatusSchema,
+  bookId: z.string().min(1).optional(),
+  chapterNumber: z.number().int().min(1).optional(),
+  detail: z.string().min(1).optional(),
+});
+
+export type InteractionEvent = z.infer<typeof InteractionEventSchema>;
+
 export function isTerminalExecutionStatus(status: ExecutionStatus): boolean {
 export function isTerminalExecutionStatus(status: ExecutionStatus): boolean {
   return status === "completed" || status === "failed" || status === "blocked";
   return status === "completed" || status === "failed" || status === "blocked";
 }
 }

+ 83 - 6
packages/core/src/interaction/runtime.ts

@@ -2,7 +2,7 @@ import type { AutomationMode } from "./modes.js";
 import { routeInteractionRequest } from "./request-router.js";
 import { routeInteractionRequest } from "./request-router.js";
 import type { InteractionRequest } from "./intents.js";
 import type { InteractionRequest } from "./intents.js";
 import type { InteractionSession } from "./session.js";
 import type { InteractionSession } from "./session.js";
-import { bindActiveBook, updateAutomationMode } from "./session.js";
+import { appendInteractionEvent, bindActiveBook, updateAutomationMode } from "./session.js";
 
 
 type ReviseMode = "local-fix" | "rewrite";
 type ReviseMode = "local-fix" | "rewrite";
 
 
@@ -15,6 +15,7 @@ export interface InteractionRuntimeTools {
 
 
 export interface InteractionRuntimeResult {
 export interface InteractionRuntimeResult {
   readonly session: InteractionSession;
   readonly session: InteractionSession;
+  readonly responseText?: string;
 }
 }
 
 
 export async function runInteractionRequest(params: {
 export async function runInteractionRequest(params: {
@@ -24,6 +25,19 @@ export async function runInteractionRequest(params: {
 }): Promise<InteractionRuntimeResult> {
 }): Promise<InteractionRuntimeResult> {
   const request = routeInteractionRequest(params.request);
   const request = routeInteractionRequest(params.request);
   let session = params.session;
   let session = params.session;
+  const addEvent = (
+    nextSession: InteractionSession,
+    kind: string,
+    status: "completed" | "blocked",
+    detail: string,
+  ): InteractionSession => appendInteractionEvent(nextSession, {
+    kind,
+    timestamp: Date.now(),
+    status,
+    bookId: nextSession.activeBookId,
+    chapterNumber: nextSession.activeChapterNumber,
+    detail,
+  });
 
 
   if (request.mode) {
   if (request.mode) {
     session = updateAutomationMode(session, request.mode as AutomationMode);
     session = updateAutomationMode(session, request.mode as AutomationMode);
@@ -48,7 +62,11 @@ export async function runInteractionRequest(params: {
       }
       }
       await params.tools.writeNextChapter(bookId);
       await params.tools.writeNextChapter(bookId);
       session = bindActiveBook(session, bookId);
       session = bindActiveBook(session, bookId);
-      return { session: markCompleted(session) };
+      const completed = markCompleted(session);
+      return {
+        session: addEvent(completed, "task.completed", "completed", `Completed write_next for ${bookId}.`),
+        responseText: `Completed write_next for ${bookId}.`,
+      };
     }
     }
     case "revise_chapter":
     case "revise_chapter":
     case "rewrite_chapter": {
     case "rewrite_chapter": {
@@ -62,7 +80,11 @@ export async function runInteractionRequest(params: {
       const mode: ReviseMode = request.intent === "rewrite_chapter" ? "rewrite" : "local-fix";
       const mode: ReviseMode = request.intent === "rewrite_chapter" ? "rewrite" : "local-fix";
       await params.tools.reviseDraft(bookId, request.chapterNumber, mode);
       await params.tools.reviseDraft(bookId, request.chapterNumber, mode);
       session = bindActiveBook(session, bookId, request.chapterNumber);
       session = bindActiveBook(session, bookId, request.chapterNumber);
-      return { session: markCompleted(session) };
+      const completed = markCompleted(session);
+      return {
+        session: addEvent(completed, "task.completed", "completed", `Completed ${request.intent} for ${bookId}.`),
+        responseText: `Completed ${request.intent} for ${bookId}.`,
+      };
     }
     }
     case "update_focus": {
     case "update_focus": {
       const bookId = request.bookId ?? session.activeBookId;
       const bookId = request.bookId ?? session.activeBookId;
@@ -74,7 +96,11 @@ export async function runInteractionRequest(params: {
       }
       }
       await params.tools.updateCurrentFocus(bookId, request.instruction);
       await params.tools.updateCurrentFocus(bookId, request.instruction);
       session = bindActiveBook(session, bookId);
       session = bindActiveBook(session, bookId);
-      return { session: markCompleted(session) };
+      const completed = markCompleted(session);
+      return {
+        session: addEvent(completed, "task.completed", "completed", `Updated current focus for ${bookId}.`),
+        responseText: `Updated current focus for ${bookId}.`,
+      };
     }
     }
     case "update_author_intent": {
     case "update_author_intent": {
       const bookId = request.bookId ?? session.activeBookId;
       const bookId = request.bookId ?? session.activeBookId;
@@ -86,12 +112,63 @@ export async function runInteractionRequest(params: {
       }
       }
       await params.tools.updateAuthorIntent(bookId, request.instruction);
       await params.tools.updateAuthorIntent(bookId, request.instruction);
       session = bindActiveBook(session, bookId);
       session = bindActiveBook(session, bookId);
-      return { session: markCompleted(session) };
+      const completed = markCompleted(session);
+      return {
+        session: addEvent(completed, "task.completed", "completed", `Updated author intent for ${bookId}.`),
+        responseText: `Updated author intent for ${bookId}.`,
+      };
     }
     }
     case "switch_mode":
     case "switch_mode":
+      session = markCompleted(session);
       return {
       return {
-        session: markCompleted(session),
+        session: addEvent(session, "task.completed", "completed", `Switched mode to ${session.automationMode}.`),
+        responseText: `Switched mode to ${session.automationMode}.`,
       };
       };
+    case "pause_book": {
+      const bookId = request.bookId ?? session.activeBookId;
+      const paused = {
+        ...session,
+        currentExecution: {
+          status: "blocked" as const,
+          bookId,
+          chapterNumber: session.activeChapterNumber,
+          stageLabel: "paused by user",
+        },
+      };
+      return {
+        session: addEvent(paused, "task.completed", "blocked", `Paused ${bookId ?? "current book"}.`),
+        responseText: `Paused ${bookId ?? "current book"}.`,
+      };
+    }
+    case "resume_book": {
+      const bookId = request.bookId ?? session.activeBookId;
+      const resumed = {
+        ...session,
+        currentExecution: {
+          status: "completed" as const,
+          bookId,
+          chapterNumber: session.activeChapterNumber,
+          stageLabel: "ready to continue",
+        },
+      };
+      return {
+        session: addEvent(resumed, "task.completed", "completed", `Resumed ${bookId ?? "current book"}.`),
+        responseText: `Resumed ${bookId ?? "current book"}.`,
+      };
+    }
+    case "explain_status":
+    case "explain_failure": {
+      const bookId = request.bookId ?? session.activeBookId;
+      const stage = session.currentExecution?.stageLabel ?? session.currentExecution?.status ?? "idle";
+      const summary = request.intent === "explain_failure"
+        ? `Current failure context: ${bookId ?? "no active book"} is at ${stage}.`
+        : `Current status: ${bookId ?? "no active book"} is at ${stage}.`;
+      const completed = markCompleted(session);
+      return {
+        session: addEvent(completed, "task.completed", "completed", summary),
+        responseText: summary,
+      };
+    }
     default:
     default:
       throw new Error(`Intent "${request.intent}" is not implemented in the interaction runtime yet.`);
       throw new Error(`Intent "${request.intent}" is not implemented in the interaction runtime yet.`);
   }
   }

+ 12 - 1
packages/core/src/interaction/session.ts

@@ -1,6 +1,6 @@
 import { z } from "zod";
 import { z } from "zod";
 import { AutomationModeSchema, type AutomationMode } from "./modes.js";
 import { AutomationModeSchema, type AutomationMode } from "./modes.js";
-import { ExecutionStateSchema } from "./events.js";
+import { ExecutionStateSchema, InteractionEventSchema, type InteractionEvent } from "./events.js";
 
 
 export const PendingDecisionSchema = z.object({
 export const PendingDecisionSchema = z.object({
   kind: z.string().min(1),
   kind: z.string().min(1),
@@ -26,6 +26,7 @@ export const InteractionSessionSchema = z.object({
   activeChapterNumber: z.number().int().min(1).optional(),
   activeChapterNumber: z.number().int().min(1).optional(),
   automationMode: AutomationModeSchema.default("semi"),
   automationMode: AutomationModeSchema.default("semi"),
   messages: z.array(InteractionMessageSchema).default([]),
   messages: z.array(InteractionMessageSchema).default([]),
+  events: z.array(InteractionEventSchema).default([]),
   pendingDecision: PendingDecisionSchema.optional(),
   pendingDecision: PendingDecisionSchema.optional(),
   currentExecution: ExecutionStateSchema.optional(),
   currentExecution: ExecutionStateSchema.optional(),
 });
 });
@@ -74,3 +75,13 @@ export function appendInteractionMessage(
     messages: [...session.messages, message].sort((left, right) => left.timestamp - right.timestamp),
     messages: [...session.messages, message].sort((left, right) => left.timestamp - right.timestamp),
   };
   };
 }
 }
+
+export function appendInteractionEvent(
+  session: InteractionSession,
+  event: InteractionEvent,
+): InteractionSession {
+  return {
+    ...session,
+    events: [...session.events, event].sort((left, right) => left.timestamp - right.timestamp),
+  };
+}