601 lines · 17.9 KB · TypeScript
RawDownload
import {  EnvironmentId,  MessageId,  ProjectId,  ProviderInstanceId,  ThreadId,  TurnId,} from "@t3tools/contracts";import { describe, expect, it } from "vite-plus/test"; import type { Thread } from "../types";import {  MAX_HIDDEN_MOUNTED_PREVIEW_THREADS,  MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,  branchMismatchKey,  buildExpiredTerminalContextToastCopy,  buildThreadTurnInterruptInput,  createLocalDispatchSnapshot,  deriveComposerSendState,  dismissBranchMismatchForSession,  getStartedThreadModelChangeBlockReason,  hasServerAcknowledgedLocalDispatch,  isBranchMismatchDismissedForSession,  reconcileMountedTerminalThreadIds,  reconcileRetainedMountedThreadIds,  resolveThreadMetadataUpdateForNextTurn,  resolveSendEnvMode,  shouldShowBranchMismatchBanner,  shouldWriteThreadErrorToCurrentServerThread,} from "./ChatView.logic"; const environmentId = EnvironmentId.make("environment-local");const projectId = ProjectId.make("project-1");const threadId = ThreadId.make("thread-1");const now = "2026-03-29T00:00:00.000Z"; function makeThread(overrides: Partial<Thread> = {}): Thread {  return {    id: threadId,    environmentId,    projectId,    title: "Thread",    modelSelection: {      instanceId: ProviderInstanceId.make("codex"),      model: "gpt-5.4",    },    runtimeMode: "full-access",    interactionMode: "default",    session: null,    messages: [],    proposedPlans: [],    activities: [],    checkpoints: [],    createdAt: now,    updatedAt: now,    archivedAt: null,    settledOverride: null,    settledAt: null,    deletedAt: null,    latestTurn: null,    branch: null,    worktreePath: null,    ...overrides,  };} const completedTurn = {  turnId: TurnId.make("turn-1"),  state: "completed" as const,  requestedAt: now,  startedAt: "2026-03-29T00:00:01.000Z",  completedAt: "2026-03-29T00:00:10.000Z",  assistantMessageId: null,}; const readySession = {  threadId,  status: "ready" as const,  providerName: "codex",  providerInstanceId: ProviderInstanceId.make("codex"),  runtimeMode: "full-access" as const,  activeTurnId: null,  lastError: null,  updatedAt: "2026-03-29T00:00:10.000Z",}; describe("resolveThreadMetadataUpdateForNextTurn", () => {  const modelSelection = {    instanceId: ProviderInstanceId.make("codex"),    model: "gpt-5.4",  };   it("updates a stale local thread branch to the active checkout", () => {    expect(      resolveThreadMetadataUpdateForNextTurn({        currentModelSelection: modelSelection,        currentBranch: "feature/thread",        nextBranch: "feature/checkout",      }),    ).toEqual({ branch: "feature/checkout", worktreePath: null });  });   it("does not write metadata when the model and branch are unchanged", () => {    expect(      resolveThreadMetadataUpdateForNextTurn({        currentModelSelection: modelSelection,        nextModelSelection: modelSelection,        currentBranch: "feature/current",        nextBranch: "feature/current",      }),    ).toBeNull();  });}); describe("buildThreadTurnInterruptInput", () => {  it("targets the session's active running turn", () => {    const activeTurnId = TurnId.make("turn-running");     expect(      buildThreadTurnInterruptInput(        makeThread({          session: {            ...readySession,            status: "running",            activeTurnId,          },        }),      ),    ).toEqual({ threadId, turnId: activeTurnId });  });   it("omits a turn id when the session is not running", () => {    expect(buildThreadTurnInterruptInput(makeThread({ session: readySession }))).toEqual({      threadId,    });  });}); describe("deriveComposerSendState", () => {  it("treats expired terminal pills as non-sendable content", () => {    const state = deriveComposerSendState({      prompt: "\uFFFC",      imageCount: 0,      terminalContexts: [        {          id: "ctx-expired",          threadId,          terminalId: "default",          terminalLabel: "Terminal 1",          lineStart: 4,          lineEnd: 4,          text: "",          createdAt: now,        },      ],    });     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,          terminalId: "default",          terminalLabel: "Terminal 1",          lineStart: 4,          lineEnd: 4,          text: "",          createdAt: now,        },      ],    });     expect(state.trimmedPrompt).toBe("yoo  waddup");    expect(state.expiredTerminalContextCount).toBe(1);    expect(state.hasSendableContent).toBe(true);  });   it("treats element contexts as sendable content (no text, no images, no terminals)", () => {    const state = deriveComposerSendState({      prompt: "",      imageCount: 0,      terminalContexts: [],      elementContextCount: 1,    });     expect(state.trimmedPrompt).toBe("");    expect(state.expiredTerminalContextCount).toBe(0);    expect(state.hasSendableContent).toBe(true);  });   it("does NOT treat zero element contexts as sendable", () => {    expect(      deriveComposerSendState({        prompt: "",        imageCount: 0,        terminalContexts: [],        elementContextCount: 0,      }).hasSendableContent,    ).toBe(false);  });}); describe("buildExpiredTerminalContextToastCopy", () => {  it("formats empty and omission 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.",    });    expect(buildExpiredTerminalContextToastCopy(2, "omitted")).toEqual({      title: "Expired terminal contexts omitted from message",      description: "Re-add it if you want that terminal output included.",    });  });}); describe("getStartedThreadModelChangeBlockReason", () => {  const providers = [    {      instanceId: ProviderInstanceId.make("codex"),    },    {      instanceId: ProviderInstanceId.make("grok"),      requiresNewThreadForModelChange: true,    },  ];   it("allows model changes before a provider session has started", () => {    expect(      getStartedThreadModelChangeBlockReason({        providers,        hasStartedSession: false,        currentModelSelection: {          instanceId: ProviderInstanceId.make("grok"),          model: "grok-build",        },        nextModelSelection: {          instanceId: ProviderInstanceId.make("grok"),          model: "grok-other",        },      }),    ).toBeNull();  });   it("allows unchanged model selections for restricted providers", () => {    expect(      getStartedThreadModelChangeBlockReason({        providers,        hasStartedSession: true,        currentModelSelection: {          instanceId: ProviderInstanceId.make("grok"),          model: "grok-build",        },        nextModelSelection: {          instanceId: ProviderInstanceId.make("grok"),          model: "grok-build",        },      }),    ).toBeNull();  });   it("blocks started-session model changes when either provider requires a new thread", () => {    expect(      getStartedThreadModelChangeBlockReason({        providers,        hasStartedSession: true,        currentModelSelection: {          instanceId: ProviderInstanceId.make("codex"),          model: "gpt-5.4",        },        nextModelSelection: {          instanceId: ProviderInstanceId.make("grok"),          model: "grok-build",        },      }),    ).toEqual({      title: "Start a new chat to change models",      description:        "This provider does not allow switching models after a conversation has started.",    });  });}); describe("resolveSendEnvMode", () => {  it("keeps worktree mode only for git repositories", () => {    expect(resolveSendEnvMode({ requestedEnvMode: "worktree", isGitRepo: true })).toBe("worktree");    expect(resolveSendEnvMode({ requestedEnvMode: "worktree", isGitRepo: false })).toBe("local");  });}); describe("branchMismatchKey", () => {  it("builds a key from thread id and both branches", () => {    expect(branchMismatchKey("thread-1", { threadBranch: "feat/a", currentBranch: "feat/b" })).toBe(      "thread-1:feat/a:feat/b",    );  });   it("returns null without a thread or mismatch", () => {    expect(branchMismatchKey(null, { threadBranch: "a", currentBranch: "b" })).toBeNull();    expect(branchMismatchKey("thread-1", null)).toBeNull();  });}); describe("shouldShowBranchMismatchBanner", () => {  const base = {    hasMismatch: true,    isDismissed: false,    composerHasContent: false,    wasShownForCurrentMismatch: false,  };   it("stays hidden during passive browsing (even though the composer autofocuses)", () => {    expect(shouldShowBranchMismatchBanner(base)).toBe(false);  });   it("shows once the composer has draft content", () => {    expect(shouldShowBranchMismatchBanner({ ...base, composerHasContent: true })).toBe(true);  });   it("stays mounted after the draft clears once shown for the current mismatch", () => {    expect(shouldShowBranchMismatchBanner({ ...base, wasShownForCurrentMismatch: true })).toBe(      true,    );  });   it("never shows when dismissed or without a mismatch", () => {    expect(      shouldShowBranchMismatchBanner({ ...base, composerHasContent: true, isDismissed: true }),    ).toBe(false);    expect(      shouldShowBranchMismatchBanner({ ...base, composerHasContent: true, hasMismatch: false }),    ).toBe(false);  });}); describe("session branch mismatch dismissal", () => {  it("tracks dismissed keys and treats other keys as active", () => {    expect(isBranchMismatchDismissedForSession("t1:a:b")).toBe(false);    dismissBranchMismatchForSession("t1:a:b");    expect(isBranchMismatchDismissedForSession("t1:a:b")).toBe(true);    expect(isBranchMismatchDismissedForSession("t1:a:c")).toBe(false);    expect(isBranchMismatchDismissedForSession(null)).toBe(false);  });}); describe("reconcileMountedTerminalThreadIds", () => {  it("keeps open threads and makes the active thread most recent", () => {    expect(      reconcileMountedTerminalThreadIds({        currentThreadIds: ["thread-a", "thread-b", "thread-c"],        openThreadIds: ["thread-a", "thread-b", "thread-c"],        activeThreadId: "thread-a",        activeThreadTerminalOpen: true,        maxHiddenThreadCount: 2,      }),    ).toEqual(["thread-b", "thread-c", "thread-a"]);  });   it("drops closed threads and enforces the hidden mounted cap", () => {    const ids = Array.from(      { length: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS + 2 },      (_, index) => `thread-${index}`,    );    expect(      reconcileMountedTerminalThreadIds({        currentThreadIds: ids,        openThreadIds: ids.slice(1),        activeThreadId: null,        activeThreadTerminalOpen: false,      }),    ).toEqual(ids.slice(-MAX_HIDDEN_MOUNTED_TERMINAL_THREADS));  });}); describe("reconcileRetainedMountedThreadIds", () => {  it("retains hidden open threads and adds the active open thread", () => {    expect(      reconcileRetainedMountedThreadIds({        currentThreadIds: [ThreadId.make("thread-hidden")],        openThreadIds: [ThreadId.make("thread-hidden")],        activeThreadId: ThreadId.make("thread-active"),        activeThreadOpen: true,        maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_PREVIEW_THREADS,      }),    ).toEqual([ThreadId.make("thread-hidden"), ThreadId.make("thread-active")]);  });   it("can retain the active thread as hidden when it is inactive", () => {    expect(      reconcileRetainedMountedThreadIds({        currentThreadIds: [ThreadId.make("thread-active")],        openThreadIds: [ThreadId.make("thread-active")],        activeThreadId: ThreadId.make("thread-active"),        activeThreadOpen: false,        maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_PREVIEW_THREADS,        retainInactiveActiveThread: true,      }),    ).toEqual([ThreadId.make("thread-active")]);  });   it("evicts the oldest hidden threads beyond the configured cap", () => {    const currentThreadIds = Array.from(      { length: MAX_HIDDEN_MOUNTED_PREVIEW_THREADS + 2 },      (_, index) => ThreadId.make(`thread-${index + 1}`),    );     expect(      reconcileRetainedMountedThreadIds({        currentThreadIds,        openThreadIds: currentThreadIds,        activeThreadId: null,        activeThreadOpen: false,        maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_PREVIEW_THREADS,      }),    ).toEqual(currentThreadIds.slice(-MAX_HIDDEN_MOUNTED_PREVIEW_THREADS));  });}); describe("shouldWriteThreadErrorToCurrentServerThread", () => {  it("requires the environment, route thread, and target thread to match", () => {    const routeThreadRef = { environmentId, threadId };     expect(      shouldWriteThreadErrorToCurrentServerThread({        serverThread: { environmentId, id: threadId },        routeThreadRef,        targetThreadId: threadId,      }),    ).toBe(true);    expect(      shouldWriteThreadErrorToCurrentServerThread({        serverThread: null,        routeThreadRef,        targetThreadId: threadId,      }),    ).toBe(false);  });}); describe("hasServerAcknowledgedLocalDispatch", () => {  it("does not acknowledge unchanged server state", () => {    const localDispatch = createLocalDispatchSnapshot(      makeThread({ latestTurn: completedTurn, session: readySession }),    );     expect(      hasServerAcknowledgedLocalDispatch({        localDispatch,        phase: "ready",        latestTurn: completedTurn,        latestUserMessageId: localDispatch.latestUserMessageId,        session: readySession,        hasPendingApproval: false,        hasPendingUserInput: false,        threadError: null,      }),    ).toBe(false);  });   it("acknowledges a settled newer turn", () => {    const localDispatch = createLocalDispatchSnapshot(      makeThread({ latestTurn: completedTurn, session: readySession }),    );    const newerTurn = {      ...completedTurn,      turnId: TurnId.make("turn-2"),      requestedAt: "2026-03-29T00:01:00.000Z",      startedAt: "2026-03-29T00:01:01.000Z",      completedAt: "2026-03-29T00:01:30.000Z",    };     expect(      hasServerAcknowledgedLocalDispatch({        localDispatch,        phase: "ready",        latestTurn: newerTurn,        latestUserMessageId: localDispatch.latestUserMessageId,        session: { ...readySession, updatedAt: newerTurn.completedAt },        hasPendingApproval: false,        hasPendingUserInput: false,        threadError: null,      }),    ).toBe(true);  });   it("waits for the matching running turn before acknowledging", () => {    const localDispatch = createLocalDispatchSnapshot(      makeThread({ latestTurn: completedTurn, session: readySession }),    );    const runningTurn = {      ...completedTurn,      turnId: TurnId.make("turn-2"),

Lines 1–500 of 601.