363 lines · 10.3 KB · TypeScript
RawDownload
import { ProjectId, ThreadId, TurnId } from "@t3tools/contracts";import { afterEach, describe, expect, it, vi } from "vitest";import { useStore } from "../store"; import {  buildExpiredTerminalContextToastCopy,  createLocalDispatchSnapshot,  deriveComposerSendState,  hasServerAcknowledgedLocalDispatch,  waitForStartedServerThread,} from "./ChatView.logic"; describe("deriveComposerSendState", () => {  it("treats expired terminal pills as non-sendable content", () => {    const state = deriveComposerSendState({      prompt: "\uFFFC",      imageCount: 0,      terminalContexts: [        {          id: "ctx-expired",          threadId: ThreadId.makeUnsafe("thread-1"),          terminalId: "default",          terminalLabel: "Terminal 1",          lineStart: 4,          lineEnd: 4,          text: "",          createdAt: "2026-03-17T12:52:29.000Z",        },      ],    });     expect(state.trimmedPrompt).toBe("");    expect(state.sendableTerminalContexts).toEqual([]);    expect(state.expiredTerminalContextCount).toBe(1);    expect(state.hasSendableContent).toBe(false);  });   it("keeps text sendable while excluding expired terminal pills", () => {    const state = deriveComposerSendState({      prompt: `yoo \uFFFC waddup`,      imageCount: 0,      terminalContexts: [        {          id: "ctx-expired",          threadId: ThreadId.makeUnsafe("thread-1"),          terminalId: "default",          terminalLabel: "Terminal 1",          lineStart: 4,          lineEnd: 4,          text: "",          createdAt: "2026-03-17T12:52:29.000Z",        },      ],    });     expect(state.trimmedPrompt).toBe("yoo  waddup");    expect(state.expiredTerminalContextCount).toBe(1);    expect(state.hasSendableContent).toBe(true);  });}); describe("buildExpiredTerminalContextToastCopy", () => {  it("formats clear empty-state guidance", () => {    expect(buildExpiredTerminalContextToastCopy(1, "empty")).toEqual({      title: "Expired terminal context won't be sent",      description: "Remove it or re-add it to include terminal output.",    });  });   it("formats omission guidance for sent messages", () => {    expect(buildExpiredTerminalContextToastCopy(2, "omitted")).toEqual({      title: "Expired terminal contexts omitted from message",      description: "Re-add it if you want that terminal output included.",    });  });}); const makeThread = (input?: {  id?: ThreadId;  latestTurn?: {    turnId: TurnId;    state: "running" | "completed";    requestedAt: string;    startedAt: string | null;    completedAt: string | null;  } | null;}) => ({  id: input?.id ?? ThreadId.makeUnsafe("thread-1"),  codexThreadId: null,  projectId: ProjectId.makeUnsafe("project-1"),  title: "Thread",  modelSelection: { provider: "codex" as const, model: "gpt-5.4" },  runtimeMode: "full-access" as const,  interactionMode: "default" as const,  session: null,  messages: [],  proposedPlans: [],  error: null,  createdAt: "2026-03-29T00:00:00.000Z",  archivedAt: null,  updatedAt: "2026-03-29T00:00:00.000Z",  latestTurn: input?.latestTurn    ? {        ...input.latestTurn,        assistantMessageId: null,      }    : null,  branch: null,  worktreePath: null,  turnDiffSummaries: [],  activities: [],}); afterEach(() => {  vi.useRealTimers();  vi.restoreAllMocks();  useStore.setState((state) => ({    ...state,    projects: [],    threads: [],    bootstrapComplete: true,  }));}); describe("waitForStartedServerThread", () => {  it("resolves immediately when the thread is already started", async () => {    const threadId = ThreadId.makeUnsafe("thread-started");    useStore.setState((state) => ({      ...state,      threads: [        makeThread({          id: threadId,          latestTurn: {            turnId: TurnId.makeUnsafe("turn-started"),            state: "running",            requestedAt: "2026-03-29T00:00:01.000Z",            startedAt: "2026-03-29T00:00:01.000Z",            completedAt: null,          },        }),      ],    }));     await expect(waitForStartedServerThread(threadId)).resolves.toBe(true);  });   it("waits for the thread to start via subscription updates", async () => {    const threadId = ThreadId.makeUnsafe("thread-wait");    useStore.setState((state) => ({      ...state,      threads: [makeThread({ id: threadId })],    }));     const promise = waitForStartedServerThread(threadId, 500);     useStore.setState((state) => ({      ...state,      threads: [        makeThread({          id: threadId,          latestTurn: {            turnId: TurnId.makeUnsafe("turn-started"),            state: "running",            requestedAt: "2026-03-29T00:00:01.000Z",            startedAt: "2026-03-29T00:00:01.000Z",            completedAt: null,          },        }),      ],    }));     await expect(promise).resolves.toBe(true);  });   it("handles the thread starting between the initial read and subscription setup", async () => {    const threadId = ThreadId.makeUnsafe("thread-race");    useStore.setState((state) => ({      ...state,      threads: [makeThread({ id: threadId })],    }));     const originalSubscribe = useStore.subscribe.bind(useStore);    let raced = false;    vi.spyOn(useStore, "subscribe").mockImplementation((listener) => {      if (!raced) {        raced = true;        useStore.setState((state) => ({          ...state,          threads: [            makeThread({              id: threadId,              latestTurn: {                turnId: TurnId.makeUnsafe("turn-race"),                state: "running",                requestedAt: "2026-03-29T00:00:01.000Z",                startedAt: "2026-03-29T00:00:01.000Z",                completedAt: null,              },            }),          ],        }));      }      return originalSubscribe(listener);    });     await expect(waitForStartedServerThread(threadId, 500)).resolves.toBe(true);  });   it("returns false after the timeout when the thread never starts", async () => {    vi.useFakeTimers();     const threadId = ThreadId.makeUnsafe("thread-timeout");    useStore.setState((state) => ({      ...state,      threads: [makeThread({ id: threadId })],    }));    const promise = waitForStartedServerThread(threadId, 500);     await vi.advanceTimersByTimeAsync(500);     await expect(promise).resolves.toBe(false);  });}); describe("hasServerAcknowledgedLocalDispatch", () => {  const projectId = ProjectId.makeUnsafe("project-1");  const previousLatestTurn = {    turnId: TurnId.makeUnsafe("turn-1"),    state: "completed" as const,    requestedAt: "2026-03-29T00:00:00.000Z",    startedAt: "2026-03-29T00:00:01.000Z",    completedAt: "2026-03-29T00:00:10.000Z",    assistantMessageId: null,  };   const previousSession = {    provider: "codex" as const,    status: "ready" as const,    createdAt: "2026-03-29T00:00:00.000Z",    updatedAt: "2026-03-29T00:00:10.000Z",    orchestrationStatus: "idle" as const,  };   it("does not clear local dispatch before server state changes", () => {    const localDispatch = createLocalDispatchSnapshot({      id: ThreadId.makeUnsafe("thread-1"),      codexThreadId: null,      projectId,      title: "Thread",      modelSelection: { provider: "codex", model: "gpt-5.4" },      runtimeMode: "full-access",      interactionMode: "default",      session: previousSession,      messages: [],      proposedPlans: [],      error: null,      createdAt: "2026-03-29T00:00:00.000Z",      archivedAt: null,      updatedAt: "2026-03-29T00:00:10.000Z",      latestTurn: previousLatestTurn,      branch: null,      worktreePath: null,      turnDiffSummaries: [],      activities: [],    });     expect(      hasServerAcknowledgedLocalDispatch({        localDispatch,        phase: "ready",        latestTurn: previousLatestTurn,        session: previousSession,        hasPendingApproval: false,        hasPendingUserInput: false,        threadError: null,      }),    ).toBe(false);  });   it("clears local dispatch when a new turn is already settled", () => {    const localDispatch = createLocalDispatchSnapshot({      id: ThreadId.makeUnsafe("thread-1"),      codexThreadId: null,      projectId,      title: "Thread",      modelSelection: { provider: "codex", model: "gpt-5.4" },      runtimeMode: "full-access",      interactionMode: "default",      session: previousSession,      messages: [],      proposedPlans: [],      error: null,      createdAt: "2026-03-29T00:00:00.000Z",      archivedAt: null,      updatedAt: "2026-03-29T00:00:10.000Z",      latestTurn: previousLatestTurn,      branch: null,      worktreePath: null,      turnDiffSummaries: [],      activities: [],    });     expect(      hasServerAcknowledgedLocalDispatch({        localDispatch,        phase: "ready",        latestTurn: {          ...previousLatestTurn,          turnId: TurnId.makeUnsafe("turn-2"),          requestedAt: "2026-03-29T00:01:00.000Z",          startedAt: "2026-03-29T00:01:01.000Z",          completedAt: "2026-03-29T00:01:30.000Z",        },        session: {          ...previousSession,          updatedAt: "2026-03-29T00:01:30.000Z",        },        hasPendingApproval: false,        hasPendingUserInput: false,        threadError: null,      }),    ).toBe(true);  });   it("clears local dispatch when the session changes without an observed running phase", () => {    const localDispatch = createLocalDispatchSnapshot({      id: ThreadId.makeUnsafe("thread-1"),      codexThreadId: null,      projectId,      title: "Thread",      modelSelection: { provider: "codex", model: "gpt-5.4" },      runtimeMode: "full-access",      interactionMode: "default",      session: previousSession,      messages: [],      proposedPlans: [],      error: null,      createdAt: "2026-03-29T00:00:00.000Z",      archivedAt: null,      updatedAt: "2026-03-29T00:00:10.000Z",      latestTurn: previousLatestTurn,      branch: null,      worktreePath: null,      turnDiffSummaries: [],      activities: [],    });     expect(      hasServerAcknowledgedLocalDispatch({        localDispatch,        phase: "ready",        latestTurn: previousLatestTurn,        session: {          ...previousSession,          updatedAt: "2026-03-29T00:00:11.000Z",        },        hasPendingApproval: false,        hasPendingUserInput: false,        threadError: null,      }),    ).toBe(true);  });});