|
|
@@ -23,10 +23,14 @@ const createInteractionToolsFromDepsMock = vi.fn(() => ({}));
|
|
|
const loadProjectSessionMock = vi.fn();
|
|
|
const resolveSessionActiveBookMock = vi.fn();
|
|
|
const runAgentSessionMock = vi.fn();
|
|
|
-const findOrCreateBookSessionMock = vi.fn();
|
|
|
+const createAndPersistBookSessionMock = vi.fn();
|
|
|
const loadBookSessionMock = vi.fn();
|
|
|
const persistBookSessionMock = vi.fn();
|
|
|
const appendBookSessionMessageMock = vi.fn();
|
|
|
+const renameBookSessionMock = vi.fn();
|
|
|
+const updateSessionTitleMock = vi.fn();
|
|
|
+const deleteBookSessionMock = vi.fn();
|
|
|
+const migrateBookSessionMock = vi.fn();
|
|
|
const resolveServiceModelMock = vi.fn();
|
|
|
const loadSecretsMock = vi.fn();
|
|
|
const saveSecretsMock = vi.fn();
|
|
|
@@ -80,6 +84,13 @@ const logger = {
|
|
|
};
|
|
|
|
|
|
vi.mock("@actalk/inkos-core", () => {
|
|
|
+ class MockSessionAlreadyMigratedError extends Error {
|
|
|
+ constructor(message = "Session already migrated") {
|
|
|
+ super(message);
|
|
|
+ this.name = "SessionAlreadyMigratedError";
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
class MockStateManager {
|
|
|
constructor(private readonly root: string) {}
|
|
|
|
|
|
@@ -163,10 +174,15 @@ vi.mock("@actalk/inkos-core", () => {
|
|
|
resolveSessionActiveBook: resolveSessionActiveBookMock,
|
|
|
runAgentSession: runAgentSessionMock,
|
|
|
buildAgentSystemPrompt: vi.fn(() => "You are helpful."),
|
|
|
- findOrCreateBookSession: findOrCreateBookSessionMock,
|
|
|
+ createAndPersistBookSession: createAndPersistBookSessionMock,
|
|
|
loadBookSession: loadBookSessionMock,
|
|
|
persistBookSession: persistBookSessionMock,
|
|
|
appendBookSessionMessage: appendBookSessionMessageMock,
|
|
|
+ renameBookSession: renameBookSessionMock,
|
|
|
+ updateSessionTitle: updateSessionTitleMock,
|
|
|
+ deleteBookSession: deleteBookSessionMock,
|
|
|
+ migrateBookSession: migrateBookSessionMock,
|
|
|
+ SessionAlreadyMigratedError: MockSessionAlreadyMigratedError,
|
|
|
resolveServicePreset: resolveServicePresetMock,
|
|
|
resolveServiceProviderFamily: resolveServiceProviderFamilyMock,
|
|
|
resolveServiceModelsBaseUrl: resolveServiceModelsBaseUrlMock,
|
|
|
@@ -326,10 +342,14 @@ describe("createStudioServer daemon lifecycle", () => {
|
|
|
rollbackToChapterMock.mockResolvedValue([]);
|
|
|
pipelineConfigs.length = 0;
|
|
|
runAgentSessionMock.mockReset();
|
|
|
- findOrCreateBookSessionMock.mockReset();
|
|
|
+ createAndPersistBookSessionMock.mockReset();
|
|
|
loadBookSessionMock.mockReset();
|
|
|
persistBookSessionMock.mockReset();
|
|
|
appendBookSessionMessageMock.mockReset();
|
|
|
+ renameBookSessionMock.mockReset();
|
|
|
+ updateSessionTitleMock.mockReset();
|
|
|
+ deleteBookSessionMock.mockReset();
|
|
|
+ migrateBookSessionMock.mockReset();
|
|
|
resolveServiceModelMock.mockReset();
|
|
|
loadSecretsMock.mockReset();
|
|
|
saveSecretsMock.mockReset();
|
|
|
@@ -341,17 +361,30 @@ describe("createStudioServer daemon lifecycle", () => {
|
|
|
// Default BookSession for agent tests
|
|
|
const defaultBookSession = {
|
|
|
sessionId: "agent-session-1",
|
|
|
- projectRoot: root,
|
|
|
- activeBookId: "demo-book",
|
|
|
+ bookId: "demo-book",
|
|
|
+ title: null,
|
|
|
messages: [],
|
|
|
events: [],
|
|
|
+ draftRounds: [],
|
|
|
+ createdAt: 1,
|
|
|
+ updatedAt: 1,
|
|
|
};
|
|
|
- findOrCreateBookSessionMock.mockResolvedValue(defaultBookSession);
|
|
|
- loadBookSessionMock.mockResolvedValue(null);
|
|
|
+ createAndPersistBookSessionMock.mockResolvedValue(defaultBookSession);
|
|
|
+ loadBookSessionMock.mockResolvedValue(defaultBookSession);
|
|
|
persistBookSessionMock.mockResolvedValue(undefined);
|
|
|
appendBookSessionMessageMock.mockImplementation(
|
|
|
(session: unknown, _msg: unknown) => session,
|
|
|
);
|
|
|
+ renameBookSessionMock.mockResolvedValue(null);
|
|
|
+ updateSessionTitleMock.mockImplementation(async (_root: string, _sessionId: string, title: string) => ({
|
|
|
+ ...defaultBookSession,
|
|
|
+ title,
|
|
|
+ }));
|
|
|
+ deleteBookSessionMock.mockResolvedValue(undefined);
|
|
|
+ migrateBookSessionMock.mockImplementation(async (_root: string, _sessionId: string, bookId: string) => ({
|
|
|
+ ...defaultBookSession,
|
|
|
+ bookId,
|
|
|
+ }));
|
|
|
runAgentSessionMock.mockResolvedValue({
|
|
|
responseText: "Agent response.",
|
|
|
messages: [],
|
|
|
@@ -1223,6 +1256,75 @@ describe("createStudioServer daemon lifecycle", () => {
|
|
|
});
|
|
|
});
|
|
|
|
|
|
+ it("creates a fresh book session on POST /api/v1/sessions", async () => {
|
|
|
+ createAndPersistBookSessionMock.mockResolvedValueOnce({
|
|
|
+ sessionId: "fresh-session",
|
|
|
+ bookId: "demo-book",
|
|
|
+ title: null,
|
|
|
+ messages: [],
|
|
|
+ events: [],
|
|
|
+ draftRounds: [],
|
|
|
+ createdAt: 10,
|
|
|
+ updatedAt: 10,
|
|
|
+ });
|
|
|
+
|
|
|
+ const { createStudioServer } = await import("./server.js");
|
|
|
+ const app = createStudioServer(cloneProjectConfig() as never, root);
|
|
|
+
|
|
|
+ const response = await app.request("http://localhost/api/v1/sessions", {
|
|
|
+ method: "POST",
|
|
|
+ headers: { "Content-Type": "application/json" },
|
|
|
+ body: JSON.stringify({ bookId: "demo-book" }),
|
|
|
+ });
|
|
|
+
|
|
|
+ expect(response.status).toBe(200);
|
|
|
+ expect(createAndPersistBookSessionMock).toHaveBeenCalledWith(root, "demo-book", undefined);
|
|
|
+ await expect(response.json()).resolves.toMatchObject({
|
|
|
+ session: { sessionId: "fresh-session", bookId: "demo-book", title: null },
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ it("renames a session through PUT /api/v1/sessions/:sessionId", async () => {
|
|
|
+ renameBookSessionMock.mockResolvedValueOnce({
|
|
|
+ sessionId: "agent-session-1",
|
|
|
+ bookId: "demo-book",
|
|
|
+ title: "新标题",
|
|
|
+ messages: [],
|
|
|
+ events: [],
|
|
|
+ draftRounds: [],
|
|
|
+ createdAt: 1,
|
|
|
+ updatedAt: 2,
|
|
|
+ });
|
|
|
+
|
|
|
+ const { createStudioServer } = await import("./server.js");
|
|
|
+ const app = createStudioServer(cloneProjectConfig() as never, root);
|
|
|
+
|
|
|
+ const response = await app.request("http://localhost/api/v1/sessions/agent-session-1", {
|
|
|
+ method: "PUT",
|
|
|
+ headers: { "Content-Type": "application/json" },
|
|
|
+ body: JSON.stringify({ title: " 新标题 " }),
|
|
|
+ });
|
|
|
+
|
|
|
+ expect(response.status).toBe(200);
|
|
|
+ expect(renameBookSessionMock).toHaveBeenCalledWith(root, "agent-session-1", "新标题");
|
|
|
+ await expect(response.json()).resolves.toMatchObject({
|
|
|
+ session: { sessionId: "agent-session-1", title: "新标题" },
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ it("deletes a session through DELETE /api/v1/sessions/:sessionId", async () => {
|
|
|
+ const { createStudioServer } = await import("./server.js");
|
|
|
+ const app = createStudioServer(cloneProjectConfig() as never, root);
|
|
|
+
|
|
|
+ const response = await app.request("http://localhost/api/v1/sessions/agent-session-1", {
|
|
|
+ method: "DELETE",
|
|
|
+ });
|
|
|
+
|
|
|
+ expect(response.status).toBe(200);
|
|
|
+ expect(deleteBookSessionMock).toHaveBeenCalledWith(root, "agent-session-1");
|
|
|
+ await expect(response.json()).resolves.toEqual({ ok: true });
|
|
|
+ });
|
|
|
+
|
|
|
it("routes /api/agent through runAgentSession and returns response + sessionId", async () => {
|
|
|
runAgentSessionMock.mockResolvedValueOnce({
|
|
|
responseText: "Completed write_next for demo-book.",
|
|
|
@@ -1238,7 +1340,7 @@ describe("createStudioServer daemon lifecycle", () => {
|
|
|
const response = await app.request("http://localhost/api/v1/agent", {
|
|
|
method: "POST",
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
- body: JSON.stringify({ instruction: "continue", activeBookId: "demo-book" }),
|
|
|
+ body: JSON.stringify({ instruction: "continue", activeBookId: "demo-book", sessionId: "agent-session-1" }),
|
|
|
});
|
|
|
|
|
|
expect(response.status).toBe(200);
|
|
|
@@ -1307,6 +1409,7 @@ describe("createStudioServer daemon lifecycle", () => {
|
|
|
instruction: "nihao",
|
|
|
service: "custom:CodexForMe",
|
|
|
model: "gpt-5.4",
|
|
|
+ sessionId: "agent-session-1",
|
|
|
}),
|
|
|
});
|
|
|
|
|
|
@@ -1325,7 +1428,7 @@ describe("createStudioServer daemon lifecycle", () => {
|
|
|
const response = await app.request("http://localhost/api/v1/agent", {
|
|
|
method: "POST",
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
- body: JSON.stringify({ instruction: "continue", activeBookId: "demo-book" }),
|
|
|
+ body: JSON.stringify({ instruction: "continue", activeBookId: "demo-book", sessionId: "agent-session-1" }),
|
|
|
});
|
|
|
|
|
|
expect(response.status).toBe(500);
|
|
|
@@ -1350,7 +1453,7 @@ describe("createStudioServer daemon lifecycle", () => {
|
|
|
const response = await app.request("http://localhost/api/v1/agent", {
|
|
|
method: "POST",
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
- body: JSON.stringify({ instruction: "nihao", activeBookId: "demo-book" }),
|
|
|
+ body: JSON.stringify({ instruction: "nihao", activeBookId: "demo-book", sessionId: "agent-session-1" }),
|
|
|
});
|
|
|
|
|
|
expect(response.status).toBe(502);
|
|
|
@@ -1379,7 +1482,7 @@ describe("createStudioServer daemon lifecycle", () => {
|
|
|
const response = await app.request("http://localhost/api/v1/agent", {
|
|
|
method: "POST",
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
- body: JSON.stringify({ instruction: "nihao", activeBookId: "demo-book" }),
|
|
|
+ body: JSON.stringify({ instruction: "nihao", activeBookId: "demo-book", sessionId: "agent-session-1" }),
|
|
|
});
|
|
|
|
|
|
expect(response.status).toBe(200);
|
|
|
@@ -1389,6 +1492,25 @@ describe("createStudioServer daemon lifecycle", () => {
|
|
|
});
|
|
|
});
|
|
|
|
|
|
+ it("rejects /api/v1/agent requests without sessionId", async () => {
|
|
|
+ const { createStudioServer } = await import("./server.js");
|
|
|
+ const app = createStudioServer(cloneProjectConfig() as never, root);
|
|
|
+
|
|
|
+ const response = await app.request("http://localhost/api/v1/agent", {
|
|
|
+ method: "POST",
|
|
|
+ headers: { "Content-Type": "application/json" },
|
|
|
+ body: JSON.stringify({ instruction: "continue", activeBookId: "demo-book" }),
|
|
|
+ });
|
|
|
+
|
|
|
+ expect(response.status).toBe(400);
|
|
|
+ await expect(response.json()).resolves.toEqual({
|
|
|
+ error: {
|
|
|
+ code: "SESSION_ID_REQUIRED",
|
|
|
+ message: "sessionId is required",
|
|
|
+ },
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
it("returns the shared interaction session state", async () => {
|
|
|
loadProjectSessionMock.mockResolvedValue({
|
|
|
sessionId: "session-2",
|