720 lines · 21.9 KB · TypeScript
RawDownload
import { scopeThreadRef } from "@t3tools/client-runtime";import { EnvironmentId, ProjectId, ThreadId, TurnId } from "@t3tools/contracts";import { afterEach, describe, expect, it, vi } from "vitest";import { type EnvironmentState, useStore } from "../store";import { type Thread } from "../types"; import {  MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,  buildExpiredTerminalContextToastCopy,  createLocalDispatchSnapshot,  deriveComposerSendState,  hasServerAcknowledgedLocalDispatch,  reconcileMountedTerminalThreadIds,  resolveSendEnvMode,  shouldWriteThreadErrorToCurrentServerThread,  waitForStartedServerThread,} from "./ChatView.logic"; const localEnvironmentId = EnvironmentId.make("environment-local"); describe("deriveComposerSendState", () => {  it("treats expired terminal pills as non-sendable content", () => {    const state = deriveComposerSendState({      prompt: "\uFFFC",      imageCount: 0,      terminalContexts: [        {          id: "ctx-expired",          threadId: ThreadId.make("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.make("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.",    });  });}); describe("resolveSendEnvMode", () => {  it("keeps worktree mode for git repositories", () => {    expect(resolveSendEnvMode({ requestedEnvMode: "worktree", isGitRepo: true })).toBe("worktree");  });   it("forces local mode for non-git repositories", () => {    expect(resolveSendEnvMode({ requestedEnvMode: "worktree", isGitRepo: false })).toBe("local");    expect(resolveSendEnvMode({ requestedEnvMode: "local", isGitRepo: false })).toBe("local");  });}); describe("reconcileMountedTerminalThreadIds", () => {  it("keeps previously mounted open threads and adds the active open thread", () => {    expect(      reconcileMountedTerminalThreadIds({        currentThreadIds: [ThreadId.make("thread-hidden"), ThreadId.make("thread-stale")],        openThreadIds: [ThreadId.make("thread-hidden"), ThreadId.make("thread-active")],        activeThreadId: ThreadId.make("thread-active"),        activeThreadTerminalOpen: true,      }),    ).toEqual([ThreadId.make("thread-hidden"), ThreadId.make("thread-active")]);  });   it("drops mounted threads once their terminal drawer is no longer open", () => {    expect(      reconcileMountedTerminalThreadIds({        currentThreadIds: [ThreadId.make("thread-closed")],        openThreadIds: [],        activeThreadId: ThreadId.make("thread-closed"),        activeThreadTerminalOpen: false,      }),    ).toEqual([]);  });   it("keeps only the most recently active hidden terminal threads", () => {    expect(      reconcileMountedTerminalThreadIds({        currentThreadIds: [          ThreadId.make("thread-1"),          ThreadId.make("thread-2"),          ThreadId.make("thread-3"),        ],        openThreadIds: [          ThreadId.make("thread-1"),          ThreadId.make("thread-2"),          ThreadId.make("thread-3"),          ThreadId.make("thread-4"),        ],        activeThreadId: ThreadId.make("thread-4"),        activeThreadTerminalOpen: true,        maxHiddenThreadCount: 2,      }),    ).toEqual([ThreadId.make("thread-2"), ThreadId.make("thread-3"), ThreadId.make("thread-4")]);  });   it("moves the active thread to the end so it is treated as most recently used", () => {    expect(      reconcileMountedTerminalThreadIds({        currentThreadIds: [          ThreadId.make("thread-a"),          ThreadId.make("thread-b"),          ThreadId.make("thread-c"),        ],        openThreadIds: [          ThreadId.make("thread-a"),          ThreadId.make("thread-b"),          ThreadId.make("thread-c"),        ],        activeThreadId: ThreadId.make("thread-a"),        activeThreadTerminalOpen: true,        maxHiddenThreadCount: 2,      }),    ).toEqual([ThreadId.make("thread-b"), ThreadId.make("thread-c"), ThreadId.make("thread-a")]);  });   it("defaults to the hidden mounted terminal cap", () => {    const currentThreadIds = Array.from(      { length: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS + 2 },      (_, index) => ThreadId.make(`thread-${index + 1}`),    );     expect(      reconcileMountedTerminalThreadIds({        currentThreadIds,        openThreadIds: currentThreadIds,        activeThreadId: null,        activeThreadTerminalOpen: false,      }),    ).toEqual(currentThreadIds.slice(-MAX_HIDDEN_MOUNTED_TERMINAL_THREADS));  });}); describe("shouldWriteThreadErrorToCurrentServerThread", () => {  it("routes errors to the active server thread when route and target match", () => {    const threadId = ThreadId.make("thread-1");    const routeThreadRef = scopeThreadRef(localEnvironmentId, threadId);     expect(      shouldWriteThreadErrorToCurrentServerThread({        serverThread: {          environmentId: localEnvironmentId,          id: threadId,        },        routeThreadRef,        targetThreadId: threadId,      }),    ).toBe(true);  });   it("does not route draft-thread errors into server-backed state", () => {    const threadId = ThreadId.make("thread-1");     expect(      shouldWriteThreadErrorToCurrentServerThread({        serverThread: undefined,        routeThreadRef: scopeThreadRef(localEnvironmentId, threadId),        targetThreadId: threadId,      }),    ).toBe(false);  });}); const makeThread = (input?: {  id?: ThreadId;  latestTurn?: {    turnId: TurnId;    state: "running" | "completed";    requestedAt: string;    startedAt: string | null;    completedAt: string | null;  } | null;}): Thread => ({  id: input?.id ?? ThreadId.make("thread-1"),  environmentId: localEnvironmentId,  codexThreadId: null,  projectId: ProjectId.make("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: [],}); function setStoreThreads(threads: ReadonlyArray<ReturnType<typeof makeThread>>) {  const projectId = ProjectId.make("project-1");  const environmentState: EnvironmentState = {    projectIds: [projectId],    projectById: {      [projectId]: {        id: projectId,        environmentId: localEnvironmentId,        name: "Project",        cwd: "/tmp/project",        defaultModelSelection: {          provider: "codex",          model: "gpt-5.4",        },        createdAt: "2026-03-29T00:00:00.000Z",        updatedAt: "2026-03-29T00:00:00.000Z",        scripts: [],      },    },    threadIds: threads.map((thread) => thread.id),    threadIdsByProjectId: {      [projectId]: threads.map((thread) => thread.id),    },    threadShellById: Object.fromEntries(      threads.map((thread) => [        thread.id,        {          id: thread.id,          environmentId: thread.environmentId,          codexThreadId: thread.codexThreadId,          projectId: thread.projectId,          title: thread.title,          modelSelection: thread.modelSelection,          runtimeMode: thread.runtimeMode,          interactionMode: thread.interactionMode,          error: thread.error,          createdAt: thread.createdAt,          archivedAt: thread.archivedAt,          updatedAt: thread.updatedAt,          branch: thread.branch,          worktreePath: thread.worktreePath,        },      ]),    ),    threadSessionById: Object.fromEntries(threads.map((thread) => [thread.id, thread.session])),    threadTurnStateById: Object.fromEntries(      threads.map((thread) => [        thread.id,        {          latestTurn: thread.latestTurn,          ...(thread.pendingSourceProposedPlan            ? { pendingSourceProposedPlan: thread.pendingSourceProposedPlan }            : {}),        },      ]),    ),    messageIdsByThreadId: Object.fromEntries(      threads.map((thread) => [thread.id, thread.messages.map((message) => message.id)]),    ),    messageByThreadId: Object.fromEntries(      threads.map((thread) => [        thread.id,        Object.fromEntries(thread.messages.map((message) => [message.id, message])),      ]),    ),    activityIdsByThreadId: Object.fromEntries(      threads.map((thread) => [thread.id, thread.activities.map((activity) => activity.id)]),    ),    activityByThreadId: Object.fromEntries(      threads.map((thread) => [        thread.id,        Object.fromEntries(thread.activities.map((activity) => [activity.id, activity])),      ]),    ),    proposedPlanIdsByThreadId: Object.fromEntries(      threads.map((thread) => [thread.id, thread.proposedPlans.map((plan) => plan.id)]),    ),    proposedPlanByThreadId: Object.fromEntries(      threads.map((thread) => [        thread.id,        Object.fromEntries(thread.proposedPlans.map((plan) => [plan.id, plan])),      ]),    ),    turnDiffIdsByThreadId: Object.fromEntries(      threads.map((thread) => [        thread.id,        thread.turnDiffSummaries.map((summary) => summary.turnId),      ]),    ),    turnDiffSummaryByThreadId: Object.fromEntries(      threads.map((thread) => [        thread.id,        Object.fromEntries(thread.turnDiffSummaries.map((summary) => [summary.turnId, summary])),      ]),    ),    sidebarThreadSummaryById: {},    bootstrapComplete: true,  };  useStore.setState({    activeEnvironmentId: localEnvironmentId,    environmentStateById: {      [localEnvironmentId]: environmentState,    },  });} afterEach(() => {  vi.useRealTimers();  vi.restoreAllMocks();  setStoreThreads([]);}); describe("waitForStartedServerThread", () => {  it("resolves immediately when the thread is already started", async () => {    const threadId = ThreadId.make("thread-started");    setStoreThreads([      makeThread({        id: threadId,        latestTurn: {          turnId: TurnId.make("turn-started"),          state: "running",          requestedAt: "2026-03-29T00:00:01.000Z",          startedAt: "2026-03-29T00:00:01.000Z",          completedAt: null,        },      }),    ]);     await expect(      waitForStartedServerThread(scopeThreadRef(localEnvironmentId, threadId)),    ).resolves.toBe(true);  });   it("waits for the thread to start via subscription updates", async () => {    const threadId = ThreadId.make("thread-wait");    setStoreThreads([makeThread({ id: threadId })]);     const promise = waitForStartedServerThread(scopeThreadRef(localEnvironmentId, threadId), 500);     setStoreThreads([      makeThread({        id: threadId,        latestTurn: {          turnId: TurnId.make("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.make("thread-race");    setStoreThreads([makeThread({ id: threadId })]);     const originalSubscribe = useStore.subscribe.bind(useStore);    let raced = false;    vi.spyOn(useStore, "subscribe").mockImplementation((listener) => {      if (!raced) {        raced = true;        setStoreThreads([          makeThread({            id: threadId,            latestTurn: {              turnId: TurnId.make("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(scopeThreadRef(localEnvironmentId, threadId), 500),    ).resolves.toBe(true);  });   it("returns false after the timeout when the thread never starts", async () => {    vi.useFakeTimers();     const threadId = ThreadId.make("thread-timeout");    setStoreThreads([makeThread({ id: threadId })]);    const promise = waitForStartedServerThread(scopeThreadRef(localEnvironmentId, threadId), 500);     await vi.advanceTimersByTimeAsync(500);     await expect(promise).resolves.toBe(false);  });}); describe("hasServerAcknowledgedLocalDispatch", () => {  const projectId = ProjectId.make("project-1");  const previousLatestTurn = {    turnId: TurnId.make("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.make("thread-1"),      environmentId: localEnvironmentId,      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({

Lines 1–500 of 720.