|
1 | 1 | import { defined } from "../tests/helpers/defined.js"; |
2 | 2 | import { afterEach, beforeEach, describe, test, expect } from "bun:test"; |
3 | | -import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises"; |
| 3 | +import { mkdtemp, mkdir, writeFile, rm, readdir } from "node:fs/promises"; |
4 | 4 | import { tmpdir } from "node:os"; |
5 | 5 | import { join, resolve } from "node:path"; |
6 | 6 |
|
@@ -50,9 +50,12 @@ import { |
50 | 50 | import { |
51 | 51 | generateSessionId, |
52 | 52 | initSessionDir, |
| 53 | + sessionContextDir, |
53 | 54 | sessionDir, |
54 | 55 | } from "./session/index.js"; |
55 | 56 | import { saveState } from "./session/state.js"; |
| 57 | +import { createOptimizedContextStore } from "./session/optimized-context-store.js"; |
| 58 | +import { projectSessionsRoot } from "./session/project-key.js"; |
56 | 59 | import { filterMcpServersForConnect } from "./trust/project-trust.js"; |
57 | 60 | import { createExaMCPServerConfig } from "./mcp/exa.js"; |
58 | 61 | import { withFileLogSink } from "../tests/helpers/file-log-sink.js"; |
@@ -122,6 +125,15 @@ async function emptyCwd(): Promise<string> { |
122 | 125 | return mkdtemp(join(tmpdir(), "ic-config-")); |
123 | 126 | } |
124 | 127 |
|
| 128 | +async function sessionIdsOnDisk(cwd: string, home: string): Promise<string[]> { |
| 129 | + try { |
| 130 | + const names = await readdir(projectSessionsRoot(cwd, home)); |
| 131 | + return names.filter((name) => name !== "latest").sort(); |
| 132 | + } catch { |
| 133 | + return []; |
| 134 | + } |
| 135 | +} |
| 136 | + |
125 | 137 | async function expectCliHelp(argv: readonly string[]): Promise<void> { |
126 | 138 | try { |
127 | 139 | await loadConfig([...argv], { globalSettingsPath: NO_SETTINGS }); |
@@ -815,6 +827,226 @@ describe("loadConfig", () => { |
815 | 827 | } |
816 | 828 | }); |
817 | 829 |
|
| 830 | + test("-p is the exec one-shot path in either flag order", async () => { |
| 831 | + const cwd = await emptyCwd(); |
| 832 | + try { |
| 833 | + const globalPath = await writeGlobalSettings(cwd); |
| 834 | + const model = "accounts/fireworks/routers/kimi-k2p6-turbo"; |
| 835 | + const viaExec = await loadConfig(["exec", "--cwd", cwd, "do the thing"], { |
| 836 | + globalSettingsPath: globalPath, |
| 837 | + }); |
| 838 | + const viaP = await loadConfig(["-p", "--cwd", cwd, "do the thing"], { |
| 839 | + globalSettingsPath: globalPath, |
| 840 | + }); |
| 841 | + const providerFirst = await loadConfig( |
| 842 | + ["-p", "--provider", "fireworks", "--cwd", cwd, "hello"], |
| 843 | + { globalSettingsPath: globalPath }, |
| 844 | + ); |
| 845 | + const modelFirst = await loadConfig( |
| 846 | + ["--model", model, "-p", "--cwd", cwd, "hello"], |
| 847 | + { globalSettingsPath: globalPath }, |
| 848 | + ); |
| 849 | + const directorFirst = await loadConfig( |
| 850 | + ["--director", "skywalker", "-p", "--cwd", cwd, "ship it"], |
| 851 | + { globalSettingsPath: globalPath }, |
| 852 | + ); |
| 853 | + assertConfigured(viaExec); |
| 854 | + assertConfigured(viaP); |
| 855 | + assertConfigured(providerFirst); |
| 856 | + assertConfigured(modelFirst); |
| 857 | + assertConfigured(directorFirst); |
| 858 | + expect(viaP.command).toBe("exec"); |
| 859 | + expect(viaP.task).toBe(viaExec.task); |
| 860 | + expect(viaP.providerName).toBe(viaExec.providerName); |
| 861 | + expect(viaP.model).toBe(viaExec.model); |
| 862 | + expect(providerFirst.command).toBe("exec"); |
| 863 | + expect(providerFirst.providerName).toBe("fireworks"); |
| 864 | + expect(providerFirst.task).toBe("hello"); |
| 865 | + expect(modelFirst.command).toBe("exec"); |
| 866 | + expect(modelFirst.model).toBe(model); |
| 867 | + expect(modelFirst.task).toBe("hello"); |
| 868 | + expect(directorFirst.command).toBe("exec"); |
| 869 | + expect(directorFirst.director).toBe("skywalker"); |
| 870 | + expect(directorFirst.task).toBe("ship it"); |
| 871 | + } finally { |
| 872 | + await rm(cwd, { recursive: true, force: true }); |
| 873 | + } |
| 874 | + }); |
| 875 | + |
| 876 | + test("exec --resume and -p --resume send the new prompt on that session", async () => { |
| 877 | + const cwd = await emptyCwd(); |
| 878 | + const home = await mkdtemp(join(tmpdir(), "ic-resume-home-")); |
| 879 | + try { |
| 880 | + const globalPath = await writeGlobalSettings(cwd); |
| 881 | + const sessionId = generateSessionId(); |
| 882 | + await initSessionDir(cwd, sessionId, home); |
| 883 | + await saveState( |
| 884 | + cwd, |
| 885 | + sessionId, |
| 886 | + { |
| 887 | + status: "done", |
| 888 | + turnsUsed: 2, |
| 889 | + task: "original task", |
| 890 | + startedAt: Date.now() - 1_000, |
| 891 | + finishedAt: Date.now(), |
| 892 | + }, |
| 893 | + home, |
| 894 | + ); |
| 895 | + const viaExec = await loadConfig( |
| 896 | + ["exec", "--resume", sessionId, "--cwd", cwd, "follow up"], |
| 897 | + { globalSettingsPath: globalPath, home }, |
| 898 | + ); |
| 899 | + const viaP = await loadConfig( |
| 900 | + ["-p", "--resume", sessionId, "--cwd", cwd, "follow up from p"], |
| 901 | + { globalSettingsPath: globalPath, home }, |
| 902 | + ); |
| 903 | + const flagOrder = await loadConfig( |
| 904 | + ["--resume", sessionId, "-p", "--cwd", cwd, "flag order"], |
| 905 | + { globalSettingsPath: globalPath, home }, |
| 906 | + ); |
| 907 | + assertConfigured(viaExec); |
| 908 | + assertConfigured(viaP); |
| 909 | + assertConfigured(flagOrder); |
| 910 | + expect(viaExec.command).toBe("exec"); |
| 911 | + expect(viaExec.resumeMode).toBe("id"); |
| 912 | + expect(viaExec.sessionId).toBe(sessionId); |
| 913 | + expect(viaExec.skipInitialTask).toBeUndefined(); |
| 914 | + expect(viaExec.resumePicker).toBeUndefined(); |
| 915 | + expect(viaExec.task).toBe("follow up"); |
| 916 | + expect(viaP.command).toBe("exec"); |
| 917 | + expect(viaP.sessionId).toBe(sessionId); |
| 918 | + expect(viaP.skipInitialTask).toBeUndefined(); |
| 919 | + expect(viaP.task).toBe("follow up from p"); |
| 920 | + expect(flagOrder.command).toBe("exec"); |
| 921 | + expect(flagOrder.sessionId).toBe(sessionId); |
| 922 | + expect(flagOrder.task).toBe("flag order"); |
| 923 | + } finally { |
| 924 | + await rm(cwd, { recursive: true, force: true }); |
| 925 | + await rm(home, { recursive: true, force: true }); |
| 926 | + } |
| 927 | + }); |
| 928 | + |
| 929 | + test("exec --resume without an id errors and does not open a picker", async () => { |
| 930 | + const cwd = await emptyCwd(); |
| 931 | + try { |
| 932 | + const globalPath = await writeGlobalSettings(cwd); |
| 933 | + await expect( |
| 934 | + loadConfig(["exec", "--resume", "--cwd", cwd], { |
| 935 | + globalSettingsPath: globalPath, |
| 936 | + }), |
| 937 | + ).rejects.toThrow("--resume requires a session id in exec mode"); |
| 938 | + await expect( |
| 939 | + loadConfig(["-p", "--resume", "--cwd", cwd, "orphan prompt"], { |
| 940 | + globalSettingsPath: globalPath, |
| 941 | + }), |
| 942 | + ).rejects.toThrow("--resume requires a session id in exec mode"); |
| 943 | + } finally { |
| 944 | + await rm(cwd, { recursive: true, force: true }); |
| 945 | + } |
| 946 | + }); |
| 947 | + |
| 948 | + test("exec --resume with a missing or unreadable id does not create a session", async () => { |
| 949 | + const cwd = await emptyCwd(); |
| 950 | + const home = await mkdtemp(join(tmpdir(), "ic-resume-home-")); |
| 951 | + try { |
| 952 | + const globalPath = await writeGlobalSettings(cwd); |
| 953 | + const missing = generateSessionId(); |
| 954 | + await expect( |
| 955 | + loadConfig(["exec", "--resume", missing, "--cwd", cwd, "follow up"], { |
| 956 | + globalSettingsPath: globalPath, |
| 957 | + home, |
| 958 | + }), |
| 959 | + ).rejects.toThrow(new RegExp(`No session ${missing}`)); |
| 960 | + expect(await sessionIdsOnDisk(cwd, home)).toEqual([]); |
| 961 | + |
| 962 | + const unreadable = generateSessionId(); |
| 963 | + await initSessionDir(cwd, unreadable, home); |
| 964 | + await writeFile(join(sessionDir(cwd, unreadable, home), "run.json"), "{"); |
| 965 | + await expect( |
| 966 | + loadConfig(["-p", "--resume", unreadable, "--cwd", cwd, "follow up"], { |
| 967 | + globalSettingsPath: globalPath, |
| 968 | + home, |
| 969 | + }), |
| 970 | + ).rejects.toBeInstanceOf(CliUserError); |
| 971 | + expect(await sessionIdsOnDisk(cwd, home)).toEqual([unreadable]); |
| 972 | + } finally { |
| 973 | + await rm(cwd, { recursive: true, force: true }); |
| 974 | + await rm(home, { recursive: true, force: true }); |
| 975 | + } |
| 976 | + }); |
| 977 | + |
| 978 | + test("a headless follow-up reopens the same context store and keeps prior turns", async () => { |
| 979 | + const cwd = await emptyCwd(); |
| 980 | + const home = await mkdtemp(join(tmpdir(), "ic-resume-home-")); |
| 981 | + try { |
| 982 | + const globalPath = await writeGlobalSettings(cwd); |
| 983 | + const sessionId = generateSessionId(); |
| 984 | + await initSessionDir(cwd, sessionId, home); |
| 985 | + await saveState( |
| 986 | + cwd, |
| 987 | + sessionId, |
| 988 | + { |
| 989 | + status: "done", |
| 990 | + turnsUsed: 1, |
| 991 | + task: "first task", |
| 992 | + startedAt: Date.now() - 1_000, |
| 993 | + finishedAt: Date.now(), |
| 994 | + }, |
| 995 | + home, |
| 996 | + ); |
| 997 | + const contextDir = sessionContextDir(cwd, sessionId, home); |
| 998 | + const first = await createOptimizedContextStore(contextDir); |
| 999 | + await first.writeTurns([ |
| 1000 | + { |
| 1001 | + role: "user", |
| 1002 | + content: [{ type: "text", text: "first task" }], |
| 1003 | + timestamp: 1, |
| 1004 | + }, |
| 1005 | + { |
| 1006 | + role: "assistant", |
| 1007 | + content: [{ type: "text", text: "first answer" }], |
| 1008 | + model: "test", |
| 1009 | + timestamp: 2, |
| 1010 | + }, |
| 1011 | + ]); |
| 1012 | + await first.writeMetadata({ |
| 1013 | + pendingOperations: [], |
| 1014 | + tokenUsage: { |
| 1015 | + input: 1, |
| 1016 | + output: 1, |
| 1017 | + cacheRead: 0, |
| 1018 | + cacheWrite: 0, |
| 1019 | + thinking: 0, |
| 1020 | + }, |
| 1021 | + }); |
| 1022 | + await first.commit({ message: "cycle" }); |
| 1023 | + |
| 1024 | + const config = await loadConfig( |
| 1025 | + ["exec", "--resume", sessionId, "--cwd", cwd, "second prompt"], |
| 1026 | + { globalSettingsPath: globalPath, home }, |
| 1027 | + ); |
| 1028 | + assertConfigured(config); |
| 1029 | + expect(config.command).toBe("exec"); |
| 1030 | + expect(config.sessionId).toBe(sessionId); |
| 1031 | + expect(config.task).toBe("second prompt"); |
| 1032 | + expect(config.skipInitialTask).toBeUndefined(); |
| 1033 | + |
| 1034 | + const reopened = await createOptimizedContextStore( |
| 1035 | + sessionContextDir(cwd, config.sessionId, home), |
| 1036 | + ); |
| 1037 | + const loaded = await reopened.load(); |
| 1038 | + expect( |
| 1039 | + loaded.turns.map((turn) => { |
| 1040 | + const block = turn.content[0]; |
| 1041 | + return block?.type === "text" ? block.text : ""; |
| 1042 | + }), |
| 1043 | + ).toEqual(["first task", "first answer"]); |
| 1044 | + } finally { |
| 1045 | + await rm(cwd, { recursive: true, force: true }); |
| 1046 | + await rm(home, { recursive: true, force: true }); |
| 1047 | + } |
| 1048 | + }); |
| 1049 | + |
818 | 1050 | test("--resume with a non-session-id token errors instead of leaking into task text", async () => { |
819 | 1051 | const cwd = await emptyCwd(); |
820 | 1052 | try { |
|
0 commit comments